diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java
index f7d13343d469..912f970ea0f4 100644
--- a/api/src/main/java/com/cloud/event/EventTypes.java
+++ b/api/src/main/java/com/cloud/event/EventTypes.java
@@ -822,6 +822,11 @@ public class EventTypes {
public static final String EVENT_QUOTA_TARIFF_DELETE = "QUOTA.TARIFF.DELETE";
public static final String EVENT_QUOTA_TARIFF_UPDATE = "QUOTA.TARIFF.UPDATE";
+ // Resource alert rules
+ public static final String EVENT_RESOURCE_ALERT_RULE_CREATE = "RESOURCE.ALERT.RULE.CREATE";
+ public static final String EVENT_RESOURCE_ALERT_RULE_UPDATE = "RESOURCE.ALERT.RULE.UPDATE";
+ public static final String EVENT_RESOURCE_ALERT_RULE_DELETE = "RESOURCE.ALERT.RULE.DELETE";
+
// Routing
public static final String EVENT_ZONE_IP4_SUBNET_CREATE = "ZONE.IP4.SUBNET.CREATE";
public static final String EVENT_ZONE_IP4_SUBNET_UPDATE = "ZONE.IP4.SUBNET.UPDATE";
diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
index f74c46161180..2bbfc8abec55 100644
--- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
+++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
@@ -1089,6 +1089,13 @@ public class ApiConstants {
public static final String AGGR_FUNCTION = "aggrfunction";
public static final String AGGR_VALUE = "aggrvalue";
public static final String THRESHOLD = "threshold";
+ public static final String METRIC = "metric";
+ public static final String CONDITION = "condition";
+ public static final String SEVERITY = "severity";
+ public static final String RESET_INTERVAL = "resetinterval";
+ public static final String WEBHOOK_IDS = "webhookids";
+ public static final String CLEANUP_WEBHOOKS = "cleanupwebhooks";
+ public static final String ALERT_RULE_ID = "alertruleid";
public static final String RELATIONAL_OPERATOR = "relationaloperator";
public static final String OTHER_DEPLOY_PARAMS = "otherdeployparams";
public static final String MIN_MEMBERS = "minmembers";
diff --git a/client/pom.xml b/client/pom.xml
index cc031a4912b1..f28b3ca9434d 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -432,6 +432,11 @@
cloud-mom-webhook
${project.version}
+
+ org.apache.cloudstack
+ cloud-plugin-resource-alerts
+ ${project.version}
+
org.apache.cloudstack
cloud-framework-agent-lb
diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
index 4cd9a8e23bfc..31167621563d 100644
--- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
+++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
@@ -34,6 +34,8 @@ public interface VolumeDao extends GenericDao, StateDao findByAccount(long accountId);
+ List listIdsByAccountOrDomainsAndState(Long accountId, List domainIds, Volume.State state);
+
List findIncludingRemovedByAccount(long accountId);
Pair getCountAndTotalByPool(long poolId);
diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java
index 1d5ff5e93402..42093c5ff4f7 100644
--- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java
+++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java
@@ -75,6 +75,7 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol
private final SearchBuilder storeAndInstallPathSearch;
private final SearchBuilder volumeIdSearch;
protected GenericSearchBuilder CountByAccount;
+ protected final GenericSearchBuilder IdsByAccountOrDomainsAndStateSearch;
protected final SearchBuilder ExternalUuidSearch;
protected GenericSearchBuilder primaryStorageSearch;
protected GenericSearchBuilder primaryStorageSearch2;
@@ -118,6 +119,21 @@ public List findByAccount(long accountId) {
return listBy(sc);
}
+ @Override
+ public List listIdsByAccountOrDomainsAndState(Long accountId, List domainIds, Volume.State state) {
+ SearchCriteria sc = IdsByAccountOrDomainsAndStateSearch.create();
+ if (accountId != null) {
+ sc.setParameters("accountId", accountId);
+ }
+ if (domainIds != null) {
+ sc.setParameters("domainIds", domainIds.toArray());
+ }
+ if (state != null) {
+ sc.setParameters("state", state);
+ }
+ return customSearch(sc, null);
+ }
+
@Override
public List findIncludingRemovedByAccount(long accountId) {
SearchCriteria sc = AllFieldsSearch.create();
@@ -420,6 +436,13 @@ public VolumeDaoImpl() {
AllFieldsSearch.and("kmsWrappedKeyId", AllFieldsSearch.entity().getKmsWrappedKeyId(), Op.EQ);
AllFieldsSearch.done();
+ IdsByAccountOrDomainsAndStateSearch = createSearchBuilder(Long.class);
+ IdsByAccountOrDomainsAndStateSearch.selectFields(IdsByAccountOrDomainsAndStateSearch.entity().getId());
+ IdsByAccountOrDomainsAndStateSearch.and("accountId", IdsByAccountOrDomainsAndStateSearch.entity().getAccountId(), Op.EQ);
+ IdsByAccountOrDomainsAndStateSearch.and("domainIds", IdsByAccountOrDomainsAndStateSearch.entity().getDomainId(), Op.IN);
+ IdsByAccountOrDomainsAndStateSearch.and("state", IdsByAccountOrDomainsAndStateSearch.entity().getState(), Op.EQ);
+ IdsByAccountOrDomainsAndStateSearch.done();
+
RootDiskStateSearch = createSearchBuilder();
RootDiskStateSearch.and("state", RootDiskStateSearch.entity().getState(), Op.IN);
RootDiskStateSearch.and("vType", RootDiskStateSearch.entity().getVolumeType(), Op.EQ);
diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDao.java
index 7de543e69d31..efb1e49f3290 100644
--- a/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDao.java
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDao.java
@@ -31,6 +31,8 @@
public interface UserVmDao extends GenericDao {
List listByAccountId(long id);
+ List listIdsByAccountOrDomainsAndState(Long accountId, List domainIds, State state);
+
List listByAccountAndPod(long accountId, long podId);
List listByAccountAndDataCenter(long accountId, long dcId);
diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDaoImpl.java
index 761053a89f0c..3cfd4fa6acb9 100644
--- a/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDaoImpl.java
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/UserVmDaoImpl.java
@@ -82,6 +82,7 @@ public class UserVmDaoImpl extends GenericDaoBase implements Use
protected SearchBuilder AccountDataCenterVirtualSearch;
protected GenericSearchBuilder CountByAccountPod;
protected GenericSearchBuilder CountByAccount;
+ protected GenericSearchBuilder IdsByAccountOrDomainsAndStateSearch;
protected GenericSearchBuilder CountActiveAccount;
protected GenericSearchBuilder PodsHavingVmsForAccount;
@@ -143,6 +144,13 @@ void init() {
AccountSearch.and("account", AccountSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
AccountSearch.done();
+ IdsByAccountOrDomainsAndStateSearch = createSearchBuilder(Long.class);
+ IdsByAccountOrDomainsAndStateSearch.selectFields(IdsByAccountOrDomainsAndStateSearch.entity().getId());
+ IdsByAccountOrDomainsAndStateSearch.and("accountId", IdsByAccountOrDomainsAndStateSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
+ IdsByAccountOrDomainsAndStateSearch.and("domainIds", IdsByAccountOrDomainsAndStateSearch.entity().getDomainId(), SearchCriteria.Op.IN);
+ IdsByAccountOrDomainsAndStateSearch.and("state", IdsByAccountOrDomainsAndStateSearch.entity().getState(), SearchCriteria.Op.EQ);
+ IdsByAccountOrDomainsAndStateSearch.done();
+
IdsSearch = createSearchBuilder();
IdsSearch.and("ids", IdsSearch.entity().getId(), SearchCriteria.Op.IN);
IdsSearch.done();
@@ -318,6 +326,21 @@ public List listByAccountId(long id) {
return listBy(sc);
}
+ @Override
+ public List listIdsByAccountOrDomainsAndState(Long accountId, List domainIds, State state) {
+ SearchCriteria sc = IdsByAccountOrDomainsAndStateSearch.create();
+ if (accountId != null) {
+ sc.setParameters("accountId", accountId);
+ }
+ if (domainIds != null) {
+ sc.setParameters("domainIds", domainIds.toArray());
+ }
+ if (state != null) {
+ sc.setParameters("state", state);
+ }
+ return customSearch(sc, null);
+ }
+
@Override
public List listByHostId(Long id) {
SearchCriteria sc = HostSearch.create();
diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
index 7c11013a17d2..9f4fbf1d2ad9 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
@@ -18,3 +18,57 @@
--;
-- Schema upgrade from 4.23.0.0 to 24.0.0
--;
+
+-- resource_alert_rules: stores per-resource or generic metric threshold rules
+CREATE TABLE IF NOT EXISTS `cloud`.`resource_alert_rules` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `uuid` varchar(255) NOT NULL UNIQUE,
+ `name` varchar(255) NOT NULL,
+ `resource_type` varchar(64) NOT NULL COMMENT 'VirtualMachine, Volume, Host, StoragePool',
+ `resource_id` bigint unsigned DEFAULT NULL COMMENT 'null = applies to all resources of the type in scope',
+ `account_id` bigint unsigned NOT NULL,
+ `domain_id` bigint unsigned NOT NULL,
+ `metric` varchar(64) NOT NULL,
+ `condition_operator` varchar(8) NOT NULL COMMENT 'GT, GTE, LT, LTE, EQ',
+ `threshold` double NOT NULL,
+ `severity` varchar(32) NOT NULL COMMENT 'CRITICAL, HIGH, MEDIUM, LOW',
+ `message` varchar(4096) DEFAULT NULL,
+ `email` tinyint(1) NOT NULL DEFAULT 0,
+ `reset_interval` int unsigned NOT NULL DEFAULT 600 COMMENT 'minimum seconds between repeat firings of this rule',
+ `created` datetime DEFAULT NULL,
+ `updated` datetime DEFAULT NULL,
+ `removed` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ INDEX `i_resource_alert_rules__account_id`(`account_id`),
+ INDEX `i_resource_alert_rules__domain_id`(`domain_id`),
+ CONSTRAINT `fk_resource_alert_rules__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_resource_alert_rules__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- resource_alerts: immutable log of fired alerts
+CREATE TABLE IF NOT EXISTS `cloud`.`resource_alerts` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `uuid` varchar(255) NOT NULL UNIQUE,
+ `alert_rule_id` bigint unsigned NOT NULL,
+ `resource_id` bigint unsigned DEFAULT NULL COMMENT 'the specific resource that triggered the alert',
+ `metric_type` varchar(64) NOT NULL,
+ `metric_value` double NOT NULL,
+ `severity` varchar(32) NOT NULL,
+ `message` varchar(4096) DEFAULT NULL,
+ `alert_timestamp` datetime NOT NULL,
+ PRIMARY KEY (`id`),
+ INDEX `i_resource_alerts__alert_rule_id`(`alert_rule_id`),
+ INDEX `i_resource_alerts__alert_timestamp`(`alert_timestamp`),
+ CONSTRAINT `fk_resource_alerts__alert_rule_id` FOREIGN KEY (`alert_rule_id`) REFERENCES `resource_alert_rules`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- resource_alert_rules_webhook: webhooks a rule delivers its alerts to
+CREATE TABLE IF NOT EXISTS `cloud`.`resource_alert_rules_webhook` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `resource_alert_rule_id` bigint unsigned NOT NULL,
+ `webhook_id` bigint unsigned NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uc_resource_alert_rules_webhook__rule_webhook`(`resource_alert_rule_id`, `webhook_id`),
+ CONSTRAINT `fk_resource_alert_rules_webhook__rule_id` FOREIGN KEY (`resource_alert_rule_id`) REFERENCES `resource_alert_rules`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_resource_alert_rules_webhook__webhook_id` FOREIGN KEY (`webhook_id`) REFERENCES `webhook`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.resource_alert_rule_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.resource_alert_rule_view.sql
new file mode 100644
index 000000000000..e0f74dd34543
--- /dev/null
+++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.resource_alert_rule_view.sql
@@ -0,0 +1,48 @@
+-- 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.
+
+-- VIEW `cloud`.`resource_alert_rule_view`;
+
+DROP VIEW IF EXISTS `cloud`.`resource_alert_rule_view`;
+CREATE VIEW `cloud`.`resource_alert_rule_view` AS
+ SELECT
+ r.id,
+ r.uuid,
+ r.name,
+ r.resource_type,
+ r.resource_id,
+ r.metric,
+ r.condition_operator,
+ r.threshold,
+ r.severity,
+ r.message,
+ r.email,
+ r.reset_interval,
+ r.created,
+ r.updated,
+ r.removed,
+ a.id account_id,
+ a.uuid account_uuid,
+ a.account_name,
+ a.type account_type,
+ d.id domain_id,
+ d.uuid domain_uuid,
+ d.name domain_name,
+ d.path domain_path
+ FROM `cloud`.`resource_alert_rules` r
+ INNER JOIN `cloud`.`account` a ON r.account_id = a.id
+ INNER JOIN `cloud`.`domain` d ON r.domain_id = d.id;
diff --git a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookServiceImpl.java b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookServiceImpl.java
index 85befa13a926..6f8c2d834a86 100644
--- a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookServiceImpl.java
+++ b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookServiceImpl.java
@@ -121,13 +121,27 @@ protected WebhookDeliveryThread getDeliveryJob(Event event, Webhook webhook, Del
.setContext(context);
WebhookDeliveryThread job = new WebhookDeliveryThread(webhook, event, caller);
job = ComponentContext.inject(job);
+ applyDeliveryConfig(job, config);
+ return job;
+ }
+
+ protected DeliveryConfig getDeliveryConfig(long domainId) {
+ return new DeliveryConfig(
+ WebhookDeliveryTries.valueIn(domainId),
+ WebhookDeliveryTimeout.valueIn(domainId),
+ WebhookDeliveryBlocklist.valueIn(domainId),
+ WebhookDeliveryBlockLocalAddresses.value(),
+ WebhookDeliveryAllowRedirects.valueIn(domainId),
+ WebhookDeliveryAllowHttp.valueIn(domainId));
+ }
+
+ protected void applyDeliveryConfig(WebhookDeliveryThread job, DeliveryConfig config) {
job.setDeliveryTries(config.tries);
job.setDeliveryTimeout(config.timeout);
job.setDestinationBlocklist(config.blocklist);
job.setBlockLocalAddresses(config.blockLocalAddresses);
job.setAllowRedirects(config.allowRedirects);
job.setAllowHttp(config.allowHttp);
- return job;
}
protected String getEventValueByFilterType(Event event, WebhookFilter.Type filterType) {
@@ -214,17 +228,7 @@ protected List getDeliveryJobs(Event event) throws EventBusException {
logger.debug("Skipping delivering {} to {} as it doesn't match filters", event, webhook);
continue;
}
- if (!domainConfigs.containsKey(webhook.getDomainId())) {
- domainConfigs.put(webhook.getDomainId(),
- new DeliveryConfig(
- WebhookDeliveryTries.valueIn(webhook.getDomainId()),
- WebhookDeliveryTimeout.valueIn(webhook.getDomainId()),
- WebhookDeliveryBlocklist.valueIn(webhook.getDomainId()),
- WebhookDeliveryBlockLocalAddresses.value(),
- WebhookDeliveryAllowRedirects.valueIn(webhook.getDomainId()),
- WebhookDeliveryAllowHttp.valueIn(webhook.getDomainId())));
- }
- DeliveryConfig config = domainConfigs.get(webhook.getDomainId());
+ DeliveryConfig config = domainConfigs.computeIfAbsent(webhook.getDomainId(), this::getDeliveryConfig);
WebhookDeliveryThread job = getDeliveryJob(event, webhook, config);
jobs.add(job);
}
@@ -274,6 +278,54 @@ protected Runnable getManualDeliveryJob(WebhookDelivery existingDelivery, Webhoo
return job;
}
+ protected List getDirectDeliveryJobs(List webhookIds, long accountId, String eventType,
+ String payload) {
+ List jobs = new ArrayList<>();
+ if (CollectionUtils.isEmpty(webhookIds)) {
+ return jobs;
+ }
+ Account account = accountManager.getAccount(accountId);
+ Event event = new Event(ManagementService.Name, EventCategory.ALERT_EVENT.getName(), eventType, null, null);
+ event.setEventUuid(UUID.randomUUID().toString());
+ event.setDescription(payload);
+ event.setResourceAccountUuid(account != null ? account.getUuid() : null);
+ for (Long webhookId : webhookIds) {
+ WebhookVO webhook = webhookDao.findById(webhookId);
+ if (webhook == null || !Webhook.State.Enabled.equals(webhook.getState())) {
+ logger.debug("Skipping delivering {} to webhook ID: {} as it is missing or disabled", event, webhookId);
+ continue;
+ }
+ if (!isEventMatchingFilters(event, webhookFiltersCache.get(webhook.getId()))) {
+ logger.debug("Skipping delivering {} to {} as it doesn't match filters", event, webhook);
+ continue;
+ }
+ WebhookDeliveryThread.WebhookDeliveryContext context =
+ new WebhookDeliveryThread.WebhookDeliveryContext<>(null, null, webhook.getId());
+ AsyncCallbackDispatcher caller =
+ AsyncCallbackDispatcher.create(this);
+ caller.setCallback(caller.getTarget().directDeliveryCompleteCallback(null, null))
+ .setContext(context);
+ WebhookDeliveryThread job = new WebhookDeliveryThread(webhook, event, caller);
+ job = ComponentContext.inject(job);
+ applyDeliveryConfig(job, getDeliveryConfig(webhook.getDomainId()));
+ jobs.add(job);
+ }
+ return jobs;
+ }
+
+ // Not persisted: webhook_delivery.event_id must reference a row in the event table.
+ protected Void directDeliveryCompleteCallback(
+ AsyncCallbackDispatcher callback,
+ WebhookDeliveryThread.WebhookDeliveryContext context) {
+ WebhookDeliveryThread.WebhookDeliveryResult result = callback.getResult();
+ if (result.isSuccess()) {
+ logger.debug("Delivered alert to webhook ID: {}", context.getRuleId());
+ } else {
+ logger.warn("Failed to deliver alert to webhook ID: {} due to: {}", context.getRuleId(), result.getResult());
+ }
+ return null;
+ }
+
protected Void deliveryCompleteCallback(
AsyncCallbackDispatcher callback,
WebhookDeliveryThread.WebhookDeliveryContext context) {
@@ -386,6 +438,35 @@ public List extends ControlledEntity> listWebhooksByAccount(long accountId) {
return webhookDao.listByAccount(accountId);
}
+ @Override
+ public ControlledEntity findWebhookByUuid(String uuid) {
+ return webhookDao.findByUuid(uuid);
+ }
+
+ @Override
+ public String getWebhookUuid(long webhookId) {
+ WebhookVO webhook = webhookDao.findByIdIncludingRemoved(webhookId);
+ return webhook != null ? webhook.getUuid() : null;
+ }
+
+ @Override
+ public void deliverToWebhooks(List webhookIds, long accountId, String eventType, String payload) {
+ for (Runnable job : getDirectDeliveryJobs(webhookIds, accountId, eventType, payload)) {
+ webhookJobExecutor.submit(loggingFailures(job, eventType));
+ }
+ }
+
+ // The executor swallows exceptions, e.g. a payload URL rejected by the blocklist at delivery time.
+ protected Runnable loggingFailures(Runnable job, String eventType) {
+ return () -> {
+ try {
+ job.run();
+ } catch (Exception e) {
+ logger.warn("Failed to deliver {} to webhook: {}", eventType, e.getMessage());
+ }
+ };
+ }
+
@Override
public void handleEvent(Event event) throws EventBusException {
List jobs = getDeliveryJobs(event);
diff --git a/plugins/event-bus/webhook/src/test/java/org/apache/cloudstack/mom/webhook/WebhookServiceImplTest.java b/plugins/event-bus/webhook/src/test/java/org/apache/cloudstack/mom/webhook/WebhookServiceImplTest.java
index e945b990c808..8f7bc831c79b 100644
--- a/plugins/event-bus/webhook/src/test/java/org/apache/cloudstack/mom/webhook/WebhookServiceImplTest.java
+++ b/plugins/event-bus/webhook/src/test/java/org/apache/cloudstack/mom/webhook/WebhookServiceImplTest.java
@@ -34,6 +34,7 @@
import org.apache.cloudstack.mom.webhook.dao.WebhookDeliveryDao;
import org.apache.cloudstack.mom.webhook.dao.WebhookFilterDao;
import org.apache.cloudstack.mom.webhook.vo.WebhookDeliveryVO;
+import org.apache.cloudstack.mom.webhook.vo.WebhookFilterVO;
import org.apache.cloudstack.mom.webhook.vo.WebhookVO;
import org.apache.cloudstack.utils.cache.LazyCache;
import org.apache.commons.lang3.StringUtils;
@@ -670,4 +671,89 @@ public void invalidateWebhookFiltersCacheInvalidatesSpecificCacheEntry() {
Mockito.verify(cache, Mockito.times(1)).invalidate(123L);
}
+
+ @Test
+ public void getDirectDeliveryJobsReturnsEmptyForNoWebhooks() {
+ Assert.assertTrue(webhookServiceImpl.getDirectDeliveryJobs(new ArrayList<>(), 1L, "RESOURCE.ALERT", "{}").isEmpty());
+ }
+
+ @Test
+ public void getDirectDeliveryJobsSkipsMissingAndDisabledWebhooks() {
+ WebhookVO disabled = Mockito.mock(WebhookVO.class);
+ Mockito.when(disabled.getState()).thenReturn(Webhook.State.Disabled);
+ Mockito.when(webhookDao.findById(1L)).thenReturn(disabled);
+ Mockito.when(webhookDao.findById(2L)).thenReturn(null);
+
+ List jobs = webhookServiceImpl.getDirectDeliveryJobs(List.of(1L, 2L), 1L, "RESOURCE.ALERT", "{}");
+
+ Assert.assertTrue(jobs.isEmpty());
+ }
+
+ @Test
+ public void getDirectDeliveryJobsBuildsAlertEventForEnabledWebhook() {
+ WebhookVO webhook = Mockito.mock(WebhookVO.class);
+ Mockito.when(webhook.getId()).thenReturn(1L);
+ Mockito.when(webhook.getState()).thenReturn(Webhook.State.Enabled);
+ Mockito.when(webhookDao.findById(1L)).thenReturn(webhook);
+ Account account = Mockito.mock(Account.class);
+ Mockito.when(account.getUuid()).thenReturn("account-uuid");
+ Mockito.when(accountManager.getAccount(5L)).thenReturn(account);
+
+ List jobs = webhookServiceImpl.getDirectDeliveryJobs(List.of(1L), 5L, "RESOURCE.ALERT", "{\"a\":1}");
+
+ Assert.assertEquals(1, jobs.size());
+ Event event = (Event) ReflectionTestUtils.getField(jobs.get(0), "event");
+ Assert.assertEquals(EventCategory.ALERT_EVENT.getName(), event.getEventCategory());
+ Assert.assertEquals("RESOURCE.ALERT", event.getEventType());
+ Assert.assertEquals("{\"a\":1}", event.getDescription());
+ Assert.assertEquals("account-uuid", event.getResourceAccountUuid());
+ }
+
+ @Test
+ public void getDirectDeliveryJobsSkipsWebhookWhenFilterExcludesEvent() {
+ WebhookVO webhook = Mockito.mock(WebhookVO.class);
+ Mockito.when(webhook.getId()).thenReturn(1L);
+ Mockito.when(webhook.getState()).thenReturn(Webhook.State.Enabled);
+ Mockito.when(webhookDao.findById(1L)).thenReturn(webhook);
+ WebhookFilterVO filter = Mockito.mock(WebhookFilterVO.class);
+ Mockito.when(filter.getType()).thenReturn(WebhookFilter.Type.EventType);
+ Mockito.when(filter.getMode()).thenReturn(WebhookFilter.Mode.Exclude);
+ Mockito.when(filter.getMatchType()).thenReturn(WebhookFilter.MatchType.Exact);
+ Mockito.when(filter.getValue()).thenReturn("RESOURCE.ALERT");
+ Mockito.when(webhookFilterDao.listByWebhook(1L)).thenReturn(List.of(filter));
+
+ List jobs = webhookServiceImpl.getDirectDeliveryJobs(List.of(1L), 5L, "RESOURCE.ALERT", "{}");
+
+ Assert.assertTrue(jobs.isEmpty());
+ }
+
+ @Test
+ public void getDirectDeliveryJobsAppliesDeliverySecuritySettings() {
+ WebhookVO webhook = Mockito.mock(WebhookVO.class);
+ Mockito.when(webhook.getId()).thenReturn(1L);
+ Mockito.when(webhook.getDomainId()).thenReturn(3L);
+ Mockito.when(webhook.getState()).thenReturn(Webhook.State.Enabled);
+ Mockito.when(webhookDao.findById(1L)).thenReturn(webhook);
+ WebhookServiceImpl.DeliveryConfig config = new WebhookServiceImpl.DeliveryConfig(2, 7, "10.0.0.0/8", true, false, true);
+ Mockito.doReturn(config).when(webhookServiceImpl).getDeliveryConfig(3L);
+
+ List jobs = webhookServiceImpl.getDirectDeliveryJobs(List.of(1L), 5L, "RESOURCE.ALERT", "{}");
+
+ Assert.assertEquals(1, jobs.size());
+ Object job = jobs.get(0);
+ Assert.assertEquals(2, ReflectionTestUtils.getField(job, "deliveryTries"));
+ Assert.assertEquals(7, ReflectionTestUtils.getField(job, "deliveryTimeout"));
+ Assert.assertEquals("10.0.0.0/8", ReflectionTestUtils.getField(job, "destinationBlocklist"));
+ Assert.assertEquals(true, ReflectionTestUtils.getField(job, "blockLocalAddresses"));
+ Assert.assertEquals(true, ReflectionTestUtils.getField(job, "allowHttp"));
+ }
+
+ @Test
+ public void loggingFailuresSwallowsAndDoesNotRethrow() {
+ Runnable failing = () -> {
+ throw new com.cloud.exception.InvalidParameterValueException("blocked IP address");
+ };
+
+ webhookServiceImpl.loggingFailures(failing, "RESOURCE.ALERT").run();
+ }
}
diff --git a/plugins/pom.xml b/plugins/pom.xml
index 92768827f658..fb5c5c751a1b 100755
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -101,6 +101,8 @@
metrics
+ resource-alerts
+
network-elements/bigswitch
network-elements/dns-notifier
network-elements/elastic-loadbalancer
diff --git a/plugins/resource-alerts/pom.xml b/plugins/resource-alerts/pom.xml
new file mode 100644
index 000000000000..b1aff0a1ac9c
--- /dev/null
+++ b/plugins/resource-alerts/pom.xml
@@ -0,0 +1,37 @@
+
+
+ 4.0.0
+ cloud-plugin-resource-alerts
+ Apache CloudStack Plugin - Resource Alerts
+
+ org.apache.cloudstack
+ cloudstack-plugins
+ 24.0.0-SNAPSHOT
+ ../pom.xml
+
+
+
+ org.apache.cloudstack
+ cloud-engine-schema
+ ${project.version}
+
+
+
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertCondition.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertCondition.java
new file mode 100644
index 000000000000..b1ff4ffc3192
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertCondition.java
@@ -0,0 +1,33 @@
+// 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.resourcealert;
+
+public enum AlertCondition {
+ GT, GTE, LT, LTE, EQ;
+
+ public boolean evaluate(double value, double threshold) {
+ switch (this) {
+ case GT: return value > threshold;
+ case GTE: return value >= threshold;
+ case LT: return value < threshold;
+ case LTE: return value <= threshold;
+ case EQ: return Double.compare(value, threshold) == 0;
+ default: return false;
+ }
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertSeverity.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertSeverity.java
new file mode 100644
index 000000000000..bf0c48e9e0d0
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/AlertSeverity.java
@@ -0,0 +1,22 @@
+// 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.resourcealert;
+
+public enum AlertSeverity {
+ CRITICAL, HIGH, MEDIUM, LOW
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlert.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlert.java
new file mode 100644
index 000000000000..7012f85db008
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlert.java
@@ -0,0 +1,34 @@
+// 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.resourcealert;
+
+import java.util.Date;
+
+import org.apache.cloudstack.api.Identity;
+import org.apache.cloudstack.api.InternalIdentity;
+
+public interface ResourceAlert extends Identity, InternalIdentity {
+
+ long getAlertRuleId();
+ Long getResourceId();
+ String getMetricType();
+ double getMetricValue();
+ AlertSeverity getSeverity();
+ String getMessage();
+ Date getAlertTimestamp();
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManager.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManager.java
new file mode 100644
index 000000000000..f055dea902fc
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManager.java
@@ -0,0 +1,22 @@
+// 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.resourcealert;
+
+public interface ResourceAlertManager {
+ void evaluateRules();
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImpl.java
new file mode 100644
index 000000000000..a9674b792e42
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImpl.java
@@ -0,0 +1,611 @@
+// 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.resourcealert;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.ToDoubleFunction;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+import javax.naming.ConfigurationException;
+
+import org.apache.cloudstack.api.Identity;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+import org.apache.cloudstack.framework.config.dao.ConfigurationDao;
+import org.apache.cloudstack.managed.context.ManagedContextRunnable;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleWebhookDao;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.utils.identity.ManagementServerNode;
+import org.apache.cloudstack.utils.mailing.MailAddress;
+import org.apache.cloudstack.utils.mailing.SMTPMailProperties;
+import org.apache.cloudstack.utils.mailing.SMTPMailSender;
+import org.apache.cloudstack.webhook.WebhookHelper;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+
+import com.cloud.cluster.ManagementServerHostVO;
+import com.cloud.cluster.dao.ManagementServerHostDao;
+import com.cloud.domain.dao.DomainDao;
+import com.cloud.event.AlertGenerator;
+import com.cloud.host.Host;
+import com.cloud.host.HostStats;
+import com.cloud.host.HostVO;
+import com.cloud.host.dao.HostDao;
+import com.cloud.server.ResourceTag;
+import com.cloud.server.StatsCollector;
+import com.cloud.storage.Storage;
+import com.cloud.storage.StorageStats;
+import com.cloud.storage.Volume;
+import com.cloud.storage.VolumeStats;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.tags.dao.ResourceTagDao;
+import com.cloud.user.Account;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.Pair;
+import com.cloud.utils.component.ComponentContext;
+import com.cloud.utils.component.ManagerBase;
+import com.cloud.utils.db.GlobalLock;
+import com.cloud.vm.UserVmVO;
+import com.cloud.vm.VirtualMachine;
+import com.cloud.vm.VmStats;
+import com.cloud.vm.dao.UserVmDao;
+import com.google.gson.JsonObject;
+
+public class ResourceAlertManagerImpl extends ManagerBase implements ResourceAlertManager, Configurable {
+
+ static final String ALERT_EVENT_TYPE = "RESOURCE.ALERT";
+
+ static final ConfigKey EVAL_INTERVAL = new ConfigKey<>("Advanced", Integer.class,
+ "resourcealert.evaluation.interval", "60",
+ "Interval in seconds between resource alert rule evaluations", false);
+
+ public static final ConfigKey RULES_PER_ACCOUNT_LIMIT = new ConfigKey<>("Advanced", Integer.class,
+ "resourcealert.per.user.limit", "20",
+ "Maximum number of resource alert rules an account can own; 0 = unlimited", true, ConfigKey.Scope.Account);
+
+ static final ConfigKey HISTORY_RETENTION_DAYS = new ConfigKey<>("Advanced", Integer.class,
+ "resourcealert.history.retention.days", "30",
+ "Number of days to keep fired resource alerts; 0 keeps them forever", true);
+
+ public static final ConfigKey DEFAULT_RESET_INTERVAL = new ConfigKey<>("Advanced", Integer.class,
+ "resourcealert.repeat.interval.default", "600",
+ "Default minimum seconds between repeat firings of a resource alert rule, used when a rule does not set one", true);
+
+ @Inject ResourceAlertRuleDao ruleDao;
+ @Inject ResourceAlertDao alertDao;
+ @Inject ResourceAlertRuleWebhookDao ruleWebhookDao;
+ @Inject UserVmDao userVmDao;
+ @Inject HostDao hostDao;
+ @Inject PrimaryDataStoreDao storagePoolDao;
+ @Inject VolumeDao volumeDao;
+ @Inject StatsCollector statsCollector;
+ @Inject ConfigurationDao configDao;
+ @Inject ResourceTagDao resourceTagDao;
+ @Inject AccountDao accountDao;
+ @Inject DomainDao domainDao;
+ @Inject ManagementServerHostDao managementServerHostDao;
+
+ private ScheduledExecutorService executor;
+ ExecutorService emailExecutor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "ResourceAlertEmailSender");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private SMTPMailSender mailSender;
+ private String[] emailRecipients;
+ private String senderAddress;
+
+ @Override
+ public boolean configure(String name, Map params) throws ConfigurationException {
+ String emailList = configDao.getValue("alert.email.addresses");
+ if (StringUtils.isNotBlank(emailList)) {
+ emailRecipients = emailList.split(",");
+ }
+ senderAddress = configDao.getValue("alert.email.sender");
+
+ Map smtpConfigs = new HashMap<>();
+ for (String key : new String[]{
+ "alert.smtp.host", "alert.smtp.port", "alert.smtp.useAuth",
+ "alert.smtp.username", "alert.smtp.password", "alert.smtp.useStartTLS",
+ "alert.smtp.enabledSecurityProtocols", "alert.smtp.timeout", "alert.smtp.connectiontimeout"}) {
+ String val = configDao.getValue(key);
+ if (val != null) smtpConfigs.put(key, val);
+ }
+ mailSender = new SMTPMailSender(smtpConfigs, "alert.smtp");
+
+ return super.configure(name, params);
+ }
+
+ @Override
+ public boolean start() {
+ int interval = EVAL_INTERVAL.value();
+ executor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "ResourceAlertEvaluator");
+ t.setDaemon(true);
+ return t;
+ });
+ executor.scheduleAtFixedRate(new EvaluationTask(), interval, interval, TimeUnit.SECONDS);
+ return true;
+ }
+
+ @Override
+ public boolean stop() {
+ if (executor != null) {
+ executor.shutdown();
+ }
+ emailExecutor.shutdown();
+ return true;
+ }
+
+ @Override
+ public void evaluateRules() {
+ List rules = ruleDao.listActive();
+ for (ResourceAlertRuleVO rule : rules) {
+ if (isOrphaned(rule)) {
+ logger.info("Removing resource alert rule {} as its owner or resource no longer exists", rule.getUuid());
+ ruleDao.remove(rule.getId());
+ continue;
+ }
+ evaluateRule(rule);
+ }
+ }
+
+ boolean isOrphaned(ResourceAlertRuleVO rule) {
+ if (accountDao.findById(rule.getAccountId()) == null) {
+ return true;
+ }
+ Long resourceId = rule.getResourceId();
+ if (resourceId == null) {
+ return false;
+ }
+ switch (rule.getResourceType()) {
+ case VirtualMachine:
+ return userVmDao.findById(resourceId) == null;
+ case Volume:
+ return volumeDao.findById(resourceId) == null;
+ case Host:
+ return hostDao.findById(resourceId) == null;
+ case StoragePool:
+ return storagePoolDao.findById(resourceId) == null;
+ default:
+ return false;
+ }
+ }
+
+ // Every management server collects stats for all hosts, so only one may evaluate or alerts fire once per server.
+ boolean isEvaluatingServer() {
+ ManagementServerHostVO msHost = managementServerHostDao.findOneByLongestRuntime();
+ return msHost != null && msHost.getMsid() == ManagementServerNode.getManagementServerId();
+ }
+
+ void removeExpiredAlerts() {
+ int days = HISTORY_RETENTION_DAYS.value();
+ if (days <= 0) {
+ return;
+ }
+ int removed = alertDao.removeOlderThan(new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days)));
+ if (removed > 0) {
+ logger.debug("Removed {} resource alerts older than {} days", removed, days);
+ }
+ }
+
+ class EvaluationTask extends ManagedContextRunnable {
+ @Override
+ protected void runInContext() {
+ GlobalLock lock = GlobalLock.getInternLock("ResourceAlertEvaluation");
+ try {
+ if (!lock.lock(5)) {
+ return;
+ }
+ try {
+ if (isEvaluatingServer()) {
+ evaluateRules();
+ removeExpiredAlerts();
+ }
+ } finally {
+ lock.unlock();
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to evaluate resource alert rules", e);
+ } finally {
+ lock.releaseRef();
+ }
+ }
+ }
+
+ private void evaluateRule(ResourceAlertRuleVO rule) {
+ ResourceAlertMetric metric = ResourceAlertMetric.valueOf(rule.getMetric());
+ boolean isGeneric = rule.getResourceId() == null;
+ for (Long resourceId : getResourceIds(rule)) {
+ try {
+ if (isGeneric) {
+ if (isOptedOut(rule.getResourceType(), resourceId)) continue;
+ if (ruleDao.existsSpecificRule(rule.getResourceType(), rule.getMetric(), resourceId)) continue;
+ }
+ Double value = getMetricValue(rule.getResourceType(), metric, resourceId);
+ if (value == null || value < 0) {
+ continue;
+ }
+ if (rule.getCondition().evaluate(value, rule.getThreshold())
+ && canFire(rule.getId(), resourceId, rule.getResetInterval())) {
+ fireAlert(rule, resourceId, value);
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to evaluate resource alert rule {} for resource {}", rule.getUuid(), resourceId, e);
+ }
+ }
+ }
+
+ private boolean isOptedOut(ResourceAlertRule.ResourceType type, long resourceId) {
+ ResourceTag.ResourceObjectType objType = null;
+ if (type == ResourceAlertRule.ResourceType.VirtualMachine) {
+ objType = ResourceTag.ResourceObjectType.UserVm;
+ } else if (type == ResourceAlertRule.ResourceType.Volume) {
+ objType = ResourceTag.ResourceObjectType.Volume;
+ }
+ if (objType == null) return false;
+ ResourceTag tag = resourceTagDao.findByKey(resourceId, objType, "resource.alert.opt.out");
+ return tag != null && "true".equalsIgnoreCase(tag.getValue());
+ }
+
+ private List getResourceIds(ResourceAlertRuleVO rule) {
+ if (rule.getResourceId() != null) {
+ return Collections.singletonList(rule.getResourceId());
+ }
+ switch (rule.getResourceType()) {
+ case VirtualMachine: {
+ Pair> scope = getGenericRuleScope(rule);
+ return scope == null ? Collections.emptyList() : userVmDao.listIdsByAccountOrDomainsAndState(
+ scope.first(), scope.second(), VirtualMachine.State.Running);
+ }
+ case Volume: {
+ Pair> scope = getGenericRuleScope(rule);
+ return scope == null ? Collections.emptyList() : volumeDao.listIdsByAccountOrDomainsAndState(
+ scope.first(), scope.second(), Volume.State.Ready);
+ }
+ case Host:
+ return hostDao.listAll().stream()
+ .filter(h -> Host.Type.Routing.equals(h.getType()))
+ .map(h -> h.getId())
+ .collect(Collectors.toList());
+ case StoragePool:
+ return storagePoolDao.listAll().stream()
+ .map(p -> p.getId())
+ .collect(Collectors.toList());
+ default:
+ return Collections.emptyList();
+ }
+ }
+
+ // Root admin rules cover the whole cloud, domain admin rules their domain tree, other rules their own account.
+ Pair> getGenericRuleScope(ResourceAlertRule rule) {
+ Account owner = accountDao.findById(rule.getAccountId());
+ if (owner == null) {
+ return null;
+ }
+ if (Account.Type.ADMIN.equals(owner.getType())) {
+ return new Pair<>(null, null);
+ }
+ if (Account.Type.DOMAIN_ADMIN.equals(owner.getType()) || Account.Type.RESOURCE_DOMAIN_ADMIN.equals(owner.getType())) {
+ return new Pair<>(null, domainDao.getDomainAndChildrenIds(owner.getDomainId()));
+ }
+ return new Pair<>(owner.getId(), null);
+ }
+
+ private Double getMetricValue(ResourceAlertRule.ResourceType type, ResourceAlertMetric metric, long resourceId) {
+ switch (metric) {
+ case CPU_UTILIZATION:
+ if (type == ResourceAlertRule.ResourceType.VirtualMachine) {
+ VmStats s = statsCollector.getVmStats(resourceId, false);
+ return s != null ? s.getCPUUtilization() : null;
+ }
+ if (type == ResourceAlertRule.ResourceType.Host) {
+ HostStats s = statsCollector.getHostStats(resourceId);
+ return s != null ? s.getCpuUtilization() : null;
+ }
+ break;
+ case MEMORY_UTILIZATION:
+ if (type == ResourceAlertRule.ResourceType.VirtualMachine) {
+ VmStats s = statsCollector.getVmStats(resourceId, false);
+ if (s == null) return null;
+ double total = s.getMemoryKBs();
+ double free = s.getIntFreeMemoryKBs();
+ // free is -1 when VM has no balloon driver
+ if (total <= 0 || free < 0) return null;
+ return (1.0 - free / total) * 100.0;
+ }
+ if (type == ResourceAlertRule.ResourceType.Host) {
+ HostStats s = statsCollector.getHostStats(resourceId);
+ if (s == null) return null;
+ double total = s.getTotalMemoryKBs();
+ double free = s.getFreeMemoryKBs();
+ if (total <= 0) return null;
+ return ((total - free) / total) * 100.0;
+ }
+ break;
+ case DISK_READ_IOPS:
+ return getVmDiskStat(type, resourceId, s -> s.getDiskReadIOs());
+ case DISK_WRITE_IOPS:
+ return getVmDiskStat(type, resourceId, s -> s.getDiskWriteIOs());
+ case DISK_READ_KBPS:
+ return getVmDiskStat(type, resourceId, s -> s.getDiskReadKBs());
+ case DISK_WRITE_KBPS:
+ return getVmDiskStat(type, resourceId, s -> s.getDiskWriteKBs());
+ case NETWORK_READ_KBPS: {
+ if (type == ResourceAlertRule.ResourceType.Host) {
+ HostStats s = statsCollector.getHostStats(resourceId);
+ return s != null ? s.getNetworkReadKBs() : null;
+ }
+ VmStats s = statsCollector.getVmStats(resourceId, false);
+ return s != null ? s.getNetworkReadKBs() : null;
+ }
+ case NETWORK_WRITE_KBPS: {
+ if (type == ResourceAlertRule.ResourceType.Host) {
+ HostStats s = statsCollector.getHostStats(resourceId);
+ return s != null ? s.getNetworkWriteKBs() : null;
+ }
+ VmStats s = statsCollector.getVmStats(resourceId, false);
+ return s != null ? s.getNetworkWriteKBs() : null;
+ }
+ case STORAGE_USED_IOPS: {
+ // only reported by storage drivers that track IOPS
+ StorageStats pool = statsCollector.getStoragePoolStats(resourceId);
+ return pool != null && pool.getUsedIops() != null ? pool.getUsedIops().doubleValue() : null;
+ }
+ case VOLUME_SIZE_GB: {
+ VolumeStats s = getVolumeStats(resourceId);
+ return s != null ? s.getPhysicalSize() / (1024.0 * 1024.0 * 1024.0) : null;
+ }
+ case LOAD_AVERAGE: {
+ HostStats s = statsCollector.getHostStats(resourceId);
+ return s != null ? s.getLoadAverage() : null;
+ }
+ case STORAGE_UTILIZATION: {
+ StorageStats pool = statsCollector.getStoragePoolStats(resourceId);
+ if (pool == null || pool.getCapacityBytes() <= 0) return null;
+ return ((double) pool.getByteUsed() / pool.getCapacityBytes()) * 100.0;
+ }
+ default:
+ break;
+ }
+ return null;
+ }
+
+ // Stats are keyed by path, except OVA volumes which are keyed by chain info.
+ private VolumeStats getVolumeStats(long volumeId) {
+ VolumeVO vol = volumeDao.findById(volumeId);
+ if (vol == null) return null;
+ String locator = Storage.ImageFormat.OVA.equals(vol.getFormat()) ? vol.getChainInfo() : vol.getPath();
+ return locator != null ? statsCollector.getVolumeStats(locator) : null;
+ }
+
+ // For volume rules, resolve the attached VM and use its aggregate disk stats.
+ private Double getVmDiskStat(ResourceAlertRule.ResourceType type, long resourceId, ToDoubleFunction extractor) {
+ long vmId = resourceId;
+ if (type == ResourceAlertRule.ResourceType.Volume) {
+ VolumeVO vol = volumeDao.findById(resourceId);
+ if (vol == null || vol.getInstanceId() == null) return null;
+ vmId = vol.getInstanceId();
+ }
+ VmStats s = statsCollector.getVmStats(vmId, false);
+ return s != null ? extractor.applyAsDouble(s) : null;
+ }
+
+ private boolean canFire(long ruleId, Long resourceId, int resetInterval) {
+ ResourceAlertVO last = alertDao.findLastFiredForRule(ruleId, resourceId);
+ if (last == null) return true;
+ long secondsSinceLast = (System.currentTimeMillis() - last.getAlertTimestamp().getTime()) / 1000;
+ return secondsSinceLast >= resetInterval;
+ }
+
+ private void fireAlert(ResourceAlertRuleVO rule, Long resourceId, double value) {
+ ResourceAlertVO alert = new ResourceAlertVO(
+ rule.getId(), resourceId, rule.getMetric(), value, rule.getSeverity(),
+ rule.getMessage(), new Date());
+ alertDao.persist(alert);
+
+ String subject = buildSubject(rule, resourceId, value);
+ String body = buildBody(rule, resourceId, value);
+ long dcId = getDataCenterId(rule.getResourceType(), resourceId);
+ publishAlertEvent(dcId, subject, body);
+ deliverToWebhooks(rule, alert, resourceId, value);
+
+ if (rule.isEmail()) {
+ sendEmail(subject, body);
+ }
+
+ logger.warn("Alert fired: rule={} metric={} resource={} value={} threshold={}",
+ rule.getUuid(), rule.getMetric(), resourceId, value, rule.getThreshold());
+ }
+
+ protected WebhookHelper getWebhookHelper() {
+ try {
+ return ComponentContext.getDelegateComponentOfType(WebhookHelper.class);
+ } catch (NoSuchBeanDefinitionException e) {
+ return null;
+ }
+ }
+
+ private void deliverToWebhooks(ResourceAlertRuleVO rule, ResourceAlertVO alert, Long resourceId, double value) {
+ List webhookIds = ruleWebhookDao.listWebhookIdsByRule(rule.getId());
+ if (webhookIds.isEmpty()) {
+ return;
+ }
+ WebhookHelper webhookHelper = getWebhookHelper();
+ if (webhookHelper == null) {
+ logger.warn("Unable to deliver alert for rule {} to webhooks as the webhook plugin is not enabled", rule.getUuid());
+ return;
+ }
+ webhookHelper.deliverToWebhooks(webhookIds, rule.getAccountId(), ALERT_EVENT_TYPE,
+ buildWebhookPayload(rule, alert, resourceId, value));
+ }
+
+ String buildWebhookPayload(ResourceAlertRuleVO rule, ResourceAlertVO alert, Long resourceId, double value) {
+ JsonObject payload = new JsonObject();
+ payload.addProperty("event", ALERT_EVENT_TYPE);
+ payload.addProperty("id", alert.getUuid());
+ payload.addProperty("ruleid", rule.getUuid());
+ payload.addProperty("rulename", rule.getName());
+ payload.addProperty("resourcetype", rule.getResourceType().name());
+ payload.addProperty("resourceid", getResourceUuid(rule.getResourceType(), resourceId));
+ payload.addProperty("metric", rule.getMetric());
+ payload.addProperty("condition", rule.getCondition().name());
+ payload.addProperty("threshold", rule.getThreshold());
+ payload.addProperty("value", value);
+ payload.addProperty("severity", rule.getSeverity().name());
+ payload.addProperty("message", rule.getMessage());
+ payload.addProperty("timestamp", alert.getAlertTimestamp().toInstant().toString());
+ return payload.toString();
+ }
+
+ private String getResourceUuid(ResourceAlertRule.ResourceType type, Long resourceId) {
+ if (resourceId == null) {
+ return null;
+ }
+ Identity resource;
+ switch (type) {
+ case VirtualMachine:
+ resource = userVmDao.findByIdIncludingRemoved(resourceId);
+ break;
+ case Volume:
+ resource = volumeDao.findByIdIncludingRemoved(resourceId);
+ break;
+ case Host:
+ resource = hostDao.findByIdIncludingRemoved(resourceId);
+ break;
+ case StoragePool:
+ resource = storagePoolDao.findByIdIncludingRemoved(resourceId);
+ break;
+ default:
+ resource = null;
+ }
+ return resource != null ? resource.getUuid() : null;
+ }
+
+ private String buildSubject(ResourceAlertRuleVO rule, Long resourceId, double value) {
+ return String.format("[%s] Resource Alert: %s %s %.2f on %s %s",
+ rule.getSeverity().name(),
+ rule.getMetric(),
+ rule.getCondition().name(),
+ rule.getThreshold(),
+ rule.getResourceType().name(),
+ resourceId);
+ }
+
+ private String buildBody(ResourceAlertRuleVO rule, Long resourceId, double value) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Rule: ").append(rule.getName()).append('\n');
+ sb.append("Resource Type: ").append(rule.getResourceType().name()).append('\n');
+ sb.append("Resource ID: ").append(resourceId).append('\n');
+ sb.append("Metric: ").append(rule.getMetric()).append('\n');
+ sb.append(String.format("Condition: %s %.2f%n", rule.getCondition().name(), rule.getThreshold()));
+ sb.append(String.format("Current Value: %.2f%n", value));
+ sb.append("Severity: ").append(rule.getSeverity().name()).append('\n');
+ if (StringUtils.isNotBlank(rule.getMessage())) {
+ sb.append("Message: ").append(rule.getMessage()).append('\n');
+ }
+ return sb.toString();
+ }
+
+ private long getDataCenterId(ResourceAlertRule.ResourceType type, long resourceId) {
+ try {
+ switch (type) {
+ case VirtualMachine: {
+ UserVmVO vm = userVmDao.findById(resourceId);
+ return vm != null ? vm.getDataCenterId() : 0L;
+ }
+ case Volume: {
+ VolumeVO vol = volumeDao.findById(resourceId);
+ return vol != null ? vol.getDataCenterId() : 0L;
+ }
+ case Host: {
+ HostVO host = hostDao.findById(resourceId);
+ return host != null ? host.getDataCenterId() : 0L;
+ }
+ case StoragePool: {
+ StoragePoolVO pool = storagePoolDao.findById(resourceId);
+ return pool != null ? pool.getDataCenterId() : 0L;
+ }
+ default:
+ return 0L;
+ }
+ } catch (Exception e) {
+ return 0L;
+ }
+ }
+
+ private void sendEmail(String subject, String body) {
+ if (mailSender == null || ArrayUtils.isEmpty(emailRecipients)) {
+ return;
+ }
+ SMTPMailProperties mailProps = new SMTPMailProperties();
+ if (StringUtils.isNotBlank(senderAddress)) {
+ mailProps.setSender(new MailAddress(senderAddress));
+ }
+ mailProps.setSubject(subject);
+ mailProps.setContent(body);
+ mailProps.setContentType("text/plain");
+
+ Set addresses = new HashSet<>();
+ for (String recipient : emailRecipients) {
+ if (StringUtils.isNotBlank(recipient)) {
+ addresses.add(new MailAddress(recipient.trim()));
+ }
+ }
+ mailProps.setRecipients(addresses);
+ emailExecutor.execute(() -> mailSender.sendMail(mailProps));
+ }
+
+ // package-private so tests can stub it without needing a Spring context
+ void publishAlertEvent(long dcId, String subject, String body) {
+ try {
+ AlertGenerator.publishAlertOnEventBus(ALERT_EVENT_TYPE, dcId, null, subject, body);
+ } catch (Exception e) {
+ logger.warn("Failed to publish resource alert on the event bus", e);
+ }
+ }
+
+ @Override
+ public String getConfigComponentName() {
+ return ResourceAlertManagerImpl.class.getSimpleName();
+ }
+
+ @Override
+ public ConfigKey>[] getConfigKeys() {
+ return new ConfigKey>[]{EVAL_INTERVAL, RULES_PER_ACCOUNT_LIMIT, DEFAULT_RESET_INTERVAL, HISTORY_RETENTION_DAYS};
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertMetric.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertMetric.java
new file mode 100644
index 000000000000..639697f9a8b9
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertMetric.java
@@ -0,0 +1,51 @@
+// 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.resourcealert;
+
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.Set;
+
+public enum ResourceAlertMetric {
+ CPU_UTILIZATION(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Host),
+ MEMORY_UTILIZATION(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Host),
+ DISK_READ_IOPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Volume),
+ DISK_WRITE_IOPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Volume),
+ DISK_READ_KBPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Volume),
+ DISK_WRITE_KBPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Volume),
+ STORAGE_UTILIZATION(ResourceAlertRule.ResourceType.StoragePool),
+ NETWORK_READ_KBPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Host),
+ NETWORK_WRITE_KBPS(ResourceAlertRule.ResourceType.VirtualMachine, ResourceAlertRule.ResourceType.Host),
+ LOAD_AVERAGE(ResourceAlertRule.ResourceType.Host),
+ VOLUME_SIZE_GB(ResourceAlertRule.ResourceType.Volume),
+ STORAGE_USED_IOPS(ResourceAlertRule.ResourceType.StoragePool);
+
+ private final Set applicableTypes;
+
+ ResourceAlertMetric(ResourceAlertRule.ResourceType... types) {
+ this.applicableTypes = EnumSet.copyOf(Arrays.asList(types));
+ }
+
+ public boolean appliesTo(ResourceAlertRule.ResourceType type) {
+ return applicableTypes.contains(type);
+ }
+
+ public boolean isPercentage() {
+ return this == CPU_UTILIZATION || this == MEMORY_UTILIZATION || this == STORAGE_UTILIZATION;
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertRule.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertRule.java
new file mode 100644
index 000000000000..6b45a4b20762
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertRule.java
@@ -0,0 +1,43 @@
+// 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.resourcealert;
+
+import java.util.Date;
+
+import org.apache.cloudstack.acl.ControlledEntity;
+import org.apache.cloudstack.api.Identity;
+import org.apache.cloudstack.api.InternalIdentity;
+
+public interface ResourceAlertRule extends ControlledEntity, Identity, InternalIdentity {
+
+ enum ResourceType {
+ VirtualMachine, Volume, Host, StoragePool
+ }
+
+ String getName();
+ ResourceType getResourceType();
+ Long getResourceId();
+ String getMetric();
+ AlertCondition getCondition();
+ double getThreshold();
+ AlertSeverity getSeverity();
+ String getMessage();
+ boolean isEmail();
+ int getResetInterval();
+ Date getCreated();
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertService.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertService.java
new file mode 100644
index 000000000000..e1d4d3596006
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertService.java
@@ -0,0 +1,38 @@
+// 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.resourcealert;
+
+import org.apache.cloudstack.api.response.ListResponse;
+import org.apache.cloudstack.resourcealert.api.command.user.CreateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.DeleteResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.ListResourceAlertRulesCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.ListResourceAlertsCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.UpdateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertResponse;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+
+import com.cloud.utils.component.PluggableService;
+
+public interface ResourceAlertService extends PluggableService {
+
+ ResourceAlertRuleResponse createResourceAlertRule(CreateResourceAlertRuleCmd cmd);
+ ListResponse listResourceAlertRules(ListResourceAlertRulesCmd cmd);
+ ResourceAlertRuleResponse updateResourceAlertRule(UpdateResourceAlertRuleCmd cmd);
+ boolean deleteResourceAlertRule(DeleteResourceAlertRuleCmd cmd);
+ ListResponse listResourceAlerts(ListResourceAlertsCmd cmd);
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImpl.java
new file mode 100644
index 000000000000..5dde93949b2e
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImpl.java
@@ -0,0 +1,534 @@
+// 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.resourcealert;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.ControlledEntity;
+import org.apache.cloudstack.api.InternalIdentity;
+import org.apache.cloudstack.api.response.ListResponse;
+import org.apache.cloudstack.resourcealert.api.command.user.CreateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.DeleteResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.ListResourceAlertRulesCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.ListResourceAlertsCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.UpdateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertResponse;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleJoinDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleWebhookDao;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleJoinVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.webhook.WebhookHelper;
+import org.apache.commons.lang3.EnumUtils;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.apache.commons.lang3.StringUtils;
+
+import com.cloud.event.ActionEvent;
+import com.cloud.event.EventTypes;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.exception.PermissionDeniedException;
+import com.cloud.host.HostVO;
+import com.cloud.host.dao.HostDao;
+import com.cloud.projects.Project;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountManager;
+import com.cloud.utils.Pair;
+import com.cloud.utils.Ternary;
+import com.cloud.utils.component.ComponentContext;
+import com.cloud.utils.component.ManagerBase;
+import com.cloud.utils.db.Filter;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+import com.cloud.vm.UserVmVO;
+import com.cloud.vm.dao.UserVmDao;
+
+import org.apache.cloudstack.context.CallContext;
+
+public class ResourceAlertServiceImpl extends ManagerBase implements ResourceAlertService {
+
+ @Inject
+ AccountManager accountManager;
+ @Inject
+ ResourceAlertRuleDao ruleDao;
+ @Inject
+ ResourceAlertRuleJoinDao ruleJoinDao;
+ @Inject
+ ResourceAlertDao alertDao;
+ @Inject
+ ResourceAlertRuleWebhookDao ruleWebhookDao;
+ @Inject
+ UserVmDao userVmDao;
+ @Inject
+ VolumeDao volumeDao;
+ @Inject
+ HostDao hostDao;
+ @Inject
+ PrimaryDataStoreDao storagePoolDao;
+
+ @Override
+ @ActionEvent(eventType = EventTypes.EVENT_RESOURCE_ALERT_RULE_CREATE, eventDescription = "creating resource alert rule")
+ public ResourceAlertRuleResponse createResourceAlertRule(CreateResourceAlertRuleCmd cmd) {
+ ResourceAlertRule.ResourceType resourceType = parseResourceType(cmd.getResourceType());
+ AlertCondition condition = parseCondition(cmd.getCondition());
+ AlertSeverity severity = parseSeverity(cmd.getSeverity());
+ ResourceAlertMetric metric = parseMetric(cmd.getMetric(), resourceType);
+
+ validateThreshold(metric, cmd.getThreshold());
+ validateResetInterval(cmd.getResetInterval());
+ int resetInterval = cmd.getResetInterval() != null ? cmd.getResetInterval() : ResourceAlertManagerImpl.DEFAULT_RESET_INTERVAL.value();
+ boolean email = cmd.getEmail() != null && cmd.getEmail();
+
+ Account caller = CallContext.current().getCallingAccount();
+ checkInfrastructureAccess(caller, resourceType);
+ checkEmailAccess(caller, email);
+ Account owner = accountManager.finalizeOwner(caller, cmd.getAccountName(), cmd.getDomainId(), null);
+
+ int limit = ResourceAlertManagerImpl.RULES_PER_ACCOUNT_LIMIT.valueIn(owner.getId());
+ if (limit > 0 && ruleDao.countActiveByAccountId(owner.getId()) >= limit) {
+ throw new InvalidParameterValueException(
+ "Account has reached the maximum of " + limit + " resource alert rules");
+ }
+ long domainId = owner.getDomainId();
+
+ InternalIdentity resource = findResourceOrFail(resourceType, cmd.getResourceId());
+ if (resource instanceof ControlledEntity) {
+ accountManager.checkAccess(owner, null, false, (ControlledEntity) resource);
+ }
+ Long resourceId = resource != null ? resource.getId() : null;
+
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO(
+ cmd.getName(), resourceType, resourceId,
+ owner.getId(), domainId,
+ metric.name(), condition, cmd.getThreshold(), severity,
+ cmd.getMessage(), email, resetInterval);
+
+ List webhookIds = resolveWebhookIds(owner, cmd.getWebhookIds());
+ ruleDao.persist(rule);
+ CallContext.current().setEventResourceId(rule.getId());
+ CallContext.current().setEventDetails("Rule: " + rule.getName());
+ if (!webhookIds.isEmpty()) {
+ ruleWebhookDao.replaceWebhooksForRule(rule.getId(), webhookIds);
+ }
+ return toRuleResponse(ruleJoinDao.findById(rule.getId()));
+ }
+
+ @Override
+ public ListResponse listResourceAlertRules(ListResourceAlertRulesCmd cmd) {
+ Long resourceId = resolveResourceIdFilter(cmd.getResourceType(), cmd.getResourceId());
+ ResourceAlertRule.ResourceType resourceType = StringUtils.isNotBlank(cmd.getResourceType()) ?
+ parseResourceType(cmd.getResourceType()) : null;
+
+ Account caller = CallContext.current().getCallingAccount();
+ List permittedAccounts = new ArrayList<>();
+ Ternary domainIdRecursiveListProject =
+ new Ternary<>(cmd.getDomainId(), cmd.isRecursive(), null);
+ accountManager.buildACLSearchParameters(caller, cmd.getId(), cmd.getAccountName(), null,
+ permittedAccounts, domainIdRecursiveListProject, cmd.listAll(), false);
+ SearchBuilder sb = createAclSearchBuilder(domainIdRecursiveListProject, permittedAccounts);
+ sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ);
+ sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ);
+ sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE);
+ sb.and("resourceType", sb.entity().getResourceType(), SearchCriteria.Op.EQ);
+ sb.and("resourceId", sb.entity().getResourceId(), SearchCriteria.Op.EQ);
+ SearchCriteria sc = createAclSearchCriteria(sb, domainIdRecursiveListProject, permittedAccounts);
+ if (cmd.getId() != null) sc.setParameters("id", cmd.getId());
+ if (StringUtils.isNotBlank(cmd.getRuleName())) sc.setParameters("name", cmd.getRuleName());
+ if (StringUtils.isNotBlank(cmd.getKeyword())) sc.setParameters("keyword", "%" + cmd.getKeyword() + "%");
+ if (resourceType != null) sc.setParameters("resourceType", resourceType);
+ if (resourceId != null) sc.setParameters("resourceId", resourceId);
+
+ Filter filter = new Filter(ResourceAlertRuleJoinVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal());
+ Pair, Integer> rules = ruleJoinDao.searchAndCount(sc, filter);
+
+ List responses = rules.first().stream()
+ .map(this::toRuleResponse)
+ .collect(Collectors.toList());
+
+ ListResponse response = new ListResponse<>();
+ response.setResponses(responses, rules.second());
+ return response;
+ }
+
+ @Override
+ @ActionEvent(eventType = EventTypes.EVENT_RESOURCE_ALERT_RULE_UPDATE, eventDescription = "updating resource alert rule")
+ public ResourceAlertRuleResponse updateResourceAlertRule(UpdateResourceAlertRuleCmd cmd) {
+ ResourceAlertRuleVO rule = findRuleForCaller(cmd.getId());
+ checkEmailAccess(CallContext.current().getCallingAccount(), Boolean.TRUE.equals(cmd.getEmail()));
+
+ if (StringUtils.isNotBlank(cmd.getName())) rule.setName(cmd.getName());
+ if (StringUtils.isNotBlank(cmd.getCondition())) rule.setCondition(parseCondition(cmd.getCondition()));
+ if (cmd.getThreshold() != null) {
+ validateThreshold(ResourceAlertMetric.valueOf(rule.getMetric()), cmd.getThreshold());
+ rule.setThreshold(cmd.getThreshold());
+ }
+ if (StringUtils.isNotBlank(cmd.getSeverity())) rule.setSeverity(parseSeverity(cmd.getSeverity()));
+ if (cmd.getMessage() != null) rule.setMessage(cmd.getMessage());
+ if (cmd.getEmail() != null) rule.setEmail(cmd.getEmail());
+ if (cmd.getResetInterval() != null) {
+ validateResetInterval(cmd.getResetInterval());
+ rule.setResetInterval(cmd.getResetInterval());
+ }
+ rule.setUpdated(new Date());
+
+ if (cmd.isCleanupWebhooks()) {
+ ruleWebhookDao.replaceWebhooksForRule(rule.getId(), new ArrayList<>());
+ } else if (cmd.getWebhookIds() != null) {
+ Account owner = accountManager.getAccount(rule.getAccountId());
+ ruleWebhookDao.replaceWebhooksForRule(rule.getId(), resolveWebhookIds(owner, cmd.getWebhookIds()));
+ }
+ ruleDao.update(rule.getId(), rule);
+ return toRuleResponse(ruleJoinDao.findById(rule.getId()));
+ }
+
+ @Override
+ @ActionEvent(eventType = EventTypes.EVENT_RESOURCE_ALERT_RULE_DELETE, eventDescription = "deleting resource alert rule")
+ public boolean deleteResourceAlertRule(DeleteResourceAlertRuleCmd cmd) {
+ findRuleForCaller(cmd.getId());
+ return ruleDao.remove(cmd.getId());
+ }
+
+ @Override
+ public ListResponse listResourceAlerts(ListResourceAlertsCmd cmd) {
+ Long resourceId = resolveResourceIdFilter(cmd.getResourceType(), cmd.getResourceId());
+ List alertRuleIds = null;
+ if (cmd.getAlertRuleId() != null) {
+ ResourceAlertRuleVO rule = ruleDao.findByUuid(cmd.getAlertRuleId());
+ if (rule == null) {
+ throw new InvalidParameterValueException("Alert rule not found: " + cmd.getAlertRuleId());
+ }
+ accountManager.checkAccess(CallContext.current().getCallingAccount(), null, true, rule);
+ alertRuleIds = new ArrayList<>(List.of(rule.getId()));
+ }
+ List visibleRuleIds = listVisibleRuleIds(cmd);
+ if (visibleRuleIds != null) {
+ if (alertRuleIds == null) {
+ alertRuleIds = visibleRuleIds;
+ } else {
+ alertRuleIds.retainAll(visibleRuleIds);
+ }
+ }
+ if (StringUtils.isNotBlank(cmd.getResourceType())) {
+ List typeRuleIds = ruleDao.listIdsByResourceType(parseResourceType(cmd.getResourceType()));
+ if (alertRuleIds == null) {
+ alertRuleIds = new ArrayList<>(typeRuleIds);
+ } else {
+ alertRuleIds.retainAll(typeRuleIds);
+ }
+ }
+ if (alertRuleIds != null && alertRuleIds.isEmpty()) {
+ ListResponse empty = new ListResponse<>();
+ empty.setResponses(new ArrayList<>(), 0);
+ return empty;
+ }
+ Pair, Integer> alerts = alertDao.searchAndCountByFilters(
+ alertRuleIds, resourceId, cmd.getSeverity(), cmd.getStartDate(), cmd.getEndDate(),
+ cmd.getStartIndex(), cmd.getPageSizeVal());
+
+ Map rules = new HashMap<>();
+ List responses = alerts.first().stream()
+ .map(alert -> toAlertResponse(alert, rules.computeIfAbsent(alert.getAlertRuleId(), ruleDao::findByIdIncludingRemoved)))
+ .collect(Collectors.toList());
+
+ ListResponse response = new ListResponse<>();
+ response.setResponses(responses, alerts.second());
+ return response;
+ }
+
+ @Override
+ public List> getCommands() {
+ List> cmds = new ArrayList<>();
+ cmds.add(CreateResourceAlertRuleCmd.class);
+ cmds.add(ListResourceAlertRulesCmd.class);
+ cmds.add(UpdateResourceAlertRuleCmd.class);
+ cmds.add(DeleteResourceAlertRuleCmd.class);
+ cmds.add(ListResourceAlertsCmd.class);
+ return cmds;
+ }
+
+ private ResourceAlertRuleResponse toRuleResponse(ResourceAlertRuleJoinVO vo) {
+ if (vo == null) return null;
+ ResourceAlertRuleResponse r = new ResourceAlertRuleResponse();
+ r.setObjectName("resourcealertrule");
+ r.setId(vo.getUuid());
+ r.setName(vo.getName());
+ r.setResourceType(vo.getResourceType() != null ? vo.getResourceType().name() : null);
+ Pair resource = describeResource(vo.getResourceType(), vo.getResourceId());
+ r.setResourceId(resource != null ? resource.first() : null);
+ r.setResourceName(resource != null ? resource.second() : null);
+ r.setMetric(vo.getMetric());
+ r.setCondition(vo.getCondition() != null ? vo.getCondition().name() : null);
+ r.setThreshold(vo.getThreshold());
+ r.setSeverity(vo.getSeverity() != null ? vo.getSeverity().name() : null);
+ r.setMessage(vo.getMessage());
+ r.setEmail(vo.isEmail());
+ r.setResetInterval(vo.getResetInterval());
+ r.setWebhookIds(getWebhookUuids(vo.getId()));
+ r.setAccountName(vo.getAccountName());
+ r.setDomainId(vo.getDomainUuid());
+ r.setDomainName(vo.getDomainName());
+ r.setCreated(vo.getCreated());
+ return r;
+ }
+
+ private ResourceAlertResponse toAlertResponse(ResourceAlertVO vo, ResourceAlertRuleVO rule) {
+ ResourceAlertResponse r = new ResourceAlertResponse();
+ r.setObjectName("resourcealert");
+ r.setId(vo.getUuid());
+ r.setAlertRuleId(rule != null ? rule.getUuid() : null);
+ Pair resource = rule != null ? describeResource(rule.getResourceType(), vo.getResourceId()) : null;
+ r.setResourceId(resource != null ? resource.first() : null);
+ r.setResourceName(resource != null ? resource.second() : null);
+ r.setResourceType(rule != null ? rule.getResourceType().name() : null);
+ r.setAlertRuleName(rule != null ? rule.getName() : null);
+ r.setMetricType(vo.getMetricType());
+ r.setMetricValue(vo.getMetricValue());
+ r.setSeverity(vo.getSeverity() != null ? vo.getSeverity().name() : null);
+ r.setMessage(vo.getMessage());
+ r.setAlertTimestamp(vo.getAlertTimestamp());
+ return r;
+ }
+
+ private InternalIdentity findResource(ResourceAlertRule.ResourceType type, String uuid) {
+ switch (type) {
+ case VirtualMachine:
+ return userVmDao.findByUuid(uuid);
+ case Volume:
+ return volumeDao.findByUuid(uuid);
+ case Host:
+ return hostDao.findByUuid(uuid);
+ case StoragePool:
+ return storagePoolDao.findByUuid(uuid);
+ default:
+ return null;
+ }
+ }
+
+ private InternalIdentity findResourceOrFail(ResourceAlertRule.ResourceType type, String uuid) {
+ if (StringUtils.isBlank(uuid)) {
+ return null;
+ }
+ InternalIdentity resource = findResource(type, uuid);
+ if (resource == null) {
+ throw new InvalidParameterValueException("Unable to find " + type.name() + " with ID " + uuid);
+ }
+ return resource;
+ }
+
+ private Long resolveResourceIdFilter(String resourceType, String uuid) {
+ if (StringUtils.isBlank(uuid)) {
+ return null;
+ }
+ if (StringUtils.isBlank(resourceType)) {
+ throw new InvalidParameterValueException("resourcetype is required when resourceid is specified");
+ }
+ return findResourceOrFail(parseResourceType(resourceType), uuid).getId();
+ }
+
+ // Returns the resource's uuid and display name, or null when the type or id is not set.
+ private Pair describeResource(ResourceAlertRule.ResourceType type, Long id) {
+ if (type == null || id == null) {
+ return null;
+ }
+ switch (type) {
+ case VirtualMachine: {
+ UserVmVO vm = userVmDao.findByIdIncludingRemoved(id);
+ return vm == null ? null : new Pair<>(vm.getUuid(),
+ StringUtils.isNotBlank(vm.getDisplayName()) ? vm.getDisplayName() : vm.getHostName());
+ }
+ case Volume: {
+ VolumeVO volume = volumeDao.findByIdIncludingRemoved(id);
+ return volume == null ? null : new Pair<>(volume.getUuid(), volume.getName());
+ }
+ case Host: {
+ HostVO host = hostDao.findByIdIncludingRemoved(id);
+ return host == null ? null : new Pair<>(host.getUuid(), host.getName());
+ }
+ case StoragePool: {
+ StoragePoolVO pool = storagePoolDao.findByIdIncludingRemoved(id);
+ return pool == null ? null : new Pair<>(pool.getUuid(), pool.getName());
+ }
+ default:
+ return null;
+ }
+ }
+
+ protected WebhookHelper getWebhookHelper() {
+ try {
+ return ComponentContext.getDelegateComponentOfType(WebhookHelper.class);
+ } catch (NoSuchBeanDefinitionException e) {
+ return null;
+ }
+ }
+
+ private List resolveWebhookIds(Account owner, List webhookUuids) {
+ List ids = new ArrayList<>();
+ if (webhookUuids == null || webhookUuids.isEmpty()) {
+ return ids;
+ }
+ WebhookHelper webhookHelper = getWebhookHelper();
+ if (webhookHelper == null) {
+ throw new InvalidParameterValueException("Webhooks are not available, the webhook plugin is not enabled");
+ }
+ for (String uuid : webhookUuids) {
+ ControlledEntity webhook = webhookHelper.findWebhookByUuid(uuid);
+ if (!(webhook instanceof InternalIdentity)) {
+ throw new InvalidParameterValueException("Unable to find webhook with ID " + uuid);
+ }
+ accountManager.checkAccess(owner, null, false, webhook);
+ long id = ((InternalIdentity) webhook).getId();
+ if (!ids.contains(id)) {
+ ids.add(id);
+ }
+ }
+ return ids;
+ }
+
+ private List getWebhookUuids(long ruleId) {
+ List ids = ruleWebhookDao.listWebhookIdsByRule(ruleId);
+ WebhookHelper webhookHelper = ids.isEmpty() ? null : getWebhookHelper();
+ if (webhookHelper == null) {
+ return new ArrayList<>();
+ }
+ return ids.stream().map(webhookHelper::getWebhookUuid).filter(Objects::nonNull).collect(Collectors.toList());
+ }
+
+ private void validateThreshold(ResourceAlertMetric metric, Double threshold) {
+ if (threshold == null || threshold < 0) {
+ throw new InvalidParameterValueException("threshold must be zero or more");
+ }
+ if (metric.isPercentage() && threshold > 100) {
+ throw new InvalidParameterValueException("threshold for " + metric.name() + " is a percentage and must be 100 or less");
+ }
+ }
+
+ private void validateResetInterval(Integer resetInterval) {
+ if (resetInterval != null && resetInterval < 0) {
+ throw new InvalidParameterValueException("resetinterval must be zero or more");
+ }
+ }
+
+ private void checkInfrastructureAccess(Account caller, ResourceAlertRule.ResourceType resourceType) {
+ boolean infra = resourceType == ResourceAlertRule.ResourceType.Host
+ || resourceType == ResourceAlertRule.ResourceType.StoragePool;
+ if (infra && !accountManager.isRootAdmin(caller.getId())) {
+ throw new PermissionDeniedException("Only root admins can create alert rules for " + resourceType.name());
+ }
+ }
+
+ private void checkEmailAccess(Account caller, boolean email) {
+ if (email && !accountManager.isRootAdmin(caller.getId())) {
+ throw new PermissionDeniedException("Only root admins can enable email for alert rules");
+ }
+ }
+
+ private ResourceAlertRuleVO findRuleForCaller(long id) {
+ ResourceAlertRuleVO rule = ruleDao.findById(id);
+ if (rule == null || rule.getRemoved() != null) {
+ throw new InvalidParameterValueException("Alert rule not found");
+ }
+ accountManager.checkAccess(CallContext.current().getCallingAccount(), null, true, rule);
+ CallContext.current().setEventResourceId(rule.getId());
+ CallContext.current().setEventDetails("Rule: " + rule.getName());
+ return rule;
+ }
+
+ private SearchBuilder createAclSearchBuilder(
+ Ternary domainIdRecursiveListProject, List permittedAccounts) {
+ SearchBuilder sb = ruleJoinDao.createSearchBuilder();
+ accountManager.buildACLSearchBuilder(sb, domainIdRecursiveListProject.first(), domainIdRecursiveListProject.second(),
+ permittedAccounts, domainIdRecursiveListProject.third());
+ return sb;
+ }
+
+ private SearchCriteria createAclSearchCriteria(SearchBuilder sb,
+ Ternary domainIdRecursiveListProject, List permittedAccounts) {
+ SearchCriteria sc = sb.create();
+ accountManager.buildACLSearchCriteria(sc, domainIdRecursiveListProject.first(), domainIdRecursiveListProject.second(),
+ permittedAccounts, domainIdRecursiveListProject.third());
+ return sc;
+ }
+
+ // Returns null when the caller can see alerts of every rule.
+ private List listVisibleRuleIds(ListResourceAlertsCmd cmd) {
+ Account caller = CallContext.current().getCallingAccount();
+ List permittedAccounts = new ArrayList<>();
+ Ternary domainIdRecursiveListProject =
+ new Ternary<>(cmd.getDomainId(), cmd.isRecursive(), null);
+ accountManager.buildACLSearchParameters(caller, null, cmd.getAccountName(), null,
+ permittedAccounts, domainIdRecursiveListProject, cmd.listAll(), false);
+ if (permittedAccounts.isEmpty() && domainIdRecursiveListProject.first() == null) {
+ return null;
+ }
+ SearchBuilder sb = createAclSearchBuilder(domainIdRecursiveListProject, permittedAccounts);
+ SearchCriteria sc = createAclSearchCriteria(sb, domainIdRecursiveListProject, permittedAccounts);
+ return ruleJoinDao.searchIncludingRemoved(sc, null, null, false).stream()
+ .map(ResourceAlertRuleJoinVO::getId)
+ .collect(Collectors.toList());
+ }
+
+ private ResourceAlertRule.ResourceType parseResourceType(String value) {
+ ResourceAlertRule.ResourceType type = EnumUtils.getEnum(ResourceAlertRule.ResourceType.class, value);
+ if (type == null) {
+ throw new InvalidParameterValueException("Invalid resourcetype: " + value);
+ }
+ return type;
+ }
+
+ private AlertCondition parseCondition(String value) {
+ AlertCondition cond = EnumUtils.getEnum(AlertCondition.class, value != null ? value.toUpperCase() : null);
+ if (cond == null) {
+ throw new InvalidParameterValueException("Invalid condition: " + value + ". Valid values: GT, GTE, LT, LTE, EQ");
+ }
+ return cond;
+ }
+
+ private AlertSeverity parseSeverity(String value) {
+ AlertSeverity sev = EnumUtils.getEnum(AlertSeverity.class, value != null ? value.toUpperCase() : null);
+ if (sev == null) {
+ throw new InvalidParameterValueException("Invalid severity: " + value + ". Valid values: CRITICAL, HIGH, MEDIUM, LOW");
+ }
+ return sev;
+ }
+
+ private ResourceAlertMetric parseMetric(String value, ResourceAlertRule.ResourceType resourceType) {
+ ResourceAlertMetric metric = EnumUtils.getEnum(ResourceAlertMetric.class, value != null ? value.toUpperCase() : null);
+ if (metric == null) {
+ throw new InvalidParameterValueException("Invalid metric: " + value);
+ }
+ if (!metric.appliesTo(resourceType)) {
+ throw new InvalidParameterValueException(
+ "Metric " + metric.name() + " does not apply to resource type " + resourceType.name());
+ }
+ return metric;
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/CreateResourceAlertRuleCmd.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/CreateResourceAlertRuleCmd.java
new file mode 100644
index 000000000000..91cb39c7cd33
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/CreateResourceAlertRuleCmd.java
@@ -0,0 +1,134 @@
+// 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.resourcealert.api.command.user;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.ResourceAlertService;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+
+import com.cloud.utils.exception.CloudRuntimeException;
+
+@APICommand(name = "createResourceAlertRule",
+ description = "Creates a resource alert rule",
+ responseObject = ResourceAlertRuleResponse.class,
+ entityType = {ResourceAlertRule.class},
+ authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User},
+ since = "24.0.0")
+public class CreateResourceAlertRuleCmd extends BaseCmd {
+
+ @Inject
+ ResourceAlertService resourceAlertService;
+
+ @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true,
+ description = "name of the alert rule")
+ private String name;
+
+ @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true,
+ description = "type of resource to monitor: VirtualMachine, Volume, Host, StoragePool")
+ private String resourceType;
+
+ @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING,
+ description = "UUID of the specific resource to monitor; omit for a generic rule covering all resources of this type")
+ private String resourceId;
+
+ @Parameter(name = ApiConstants.METRIC, type = CommandType.STRING, required = true,
+ description = "metric to monitor (e.g. CPU_UTILIZATION, MEMORY_UTILIZATION)")
+ private String metric;
+
+ @Parameter(name = ApiConstants.CONDITION, type = CommandType.STRING, required = true,
+ description = "comparison operator: GT, GTE, LT, LTE, EQ")
+ private String condition;
+
+ @Parameter(name = ApiConstants.THRESHOLD, type = CommandType.DOUBLE, required = true,
+ description = "threshold value that triggers the alert")
+ private Double threshold;
+
+ @Parameter(name = ApiConstants.SEVERITY, type = CommandType.STRING, required = true,
+ description = "alert severity: CRITICAL, HIGH, MEDIUM, LOW")
+ private String severity;
+
+ @Parameter(name = ApiConstants.MESSAGE, type = CommandType.STRING,
+ description = "custom message to include in the alert")
+ private String message;
+
+ @Parameter(name = ApiConstants.EMAIL, type = CommandType.BOOLEAN,
+ description = "true to send email notification when this rule fires (admin SMTP must be configured)")
+ private Boolean email;
+
+ @Parameter(name = ApiConstants.RESET_INTERVAL, type = CommandType.INTEGER,
+ description = "minimum seconds between repeat firings of this rule; defaults to resourcealert.repeat.interval.default")
+ private Integer resetInterval;
+
+ @Parameter(name = ApiConstants.WEBHOOK_IDS, type = CommandType.LIST, collectionType = CommandType.STRING,
+ description = "UUIDs of webhooks to deliver alerts of this rule to; the rule owner must have access to them")
+ private List webhookIds;
+
+ @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING,
+ description = "account to associate this rule with (defaults to caller)")
+ private String accountName;
+
+ @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID,
+ entityType = org.apache.cloudstack.api.response.DomainResponse.class,
+ description = "domain to associate this rule with")
+ private Long domainId;
+
+ public String getName() { return name; }
+ public String getResourceType() { return resourceType; }
+ public String getResourceId() { return resourceId; }
+ public String getMetric() { return metric; }
+ public String getCondition() { return condition; }
+ public Double getThreshold() { return threshold; }
+ public String getSeverity() { return severity; }
+ public String getMessage() { return message; }
+ public Boolean getEmail() { return email; }
+ public Integer getResetInterval() { return resetInterval; }
+ public List getWebhookIds() { return webhookIds; }
+ public String getAccountName() { return accountName; }
+ public Long getDomainId() { return domainId; }
+
+ @Override
+ public long getEntityOwnerId() {
+ return CallContext.current().getCallingAccountId();
+ }
+
+ @Override
+ public void execute() throws ServerApiException {
+ try {
+ ResourceAlertRuleResponse response = resourceAlertService.createResourceAlertRule(this);
+ if (response == null) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create resource alert rule");
+ }
+ response.setResponseName(getCommandName());
+ setResponseObject(response);
+ } catch (CloudRuntimeException e) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage());
+ }
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/DeleteResourceAlertRuleCmd.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/DeleteResourceAlertRuleCmd.java
new file mode 100644
index 000000000000..e638b2d96dc0
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/DeleteResourceAlertRuleCmd.java
@@ -0,0 +1,74 @@
+// 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.resourcealert.api.command.user;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.response.SuccessResponse;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.ResourceAlertService;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+
+import com.cloud.utils.exception.CloudRuntimeException;
+
+@APICommand(name = "deleteResourceAlertRule",
+ description = "Deletes a resource alert rule",
+ responseObject = SuccessResponse.class,
+ entityType = {ResourceAlertRule.class},
+ authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User},
+ since = "24.0.0")
+public class DeleteResourceAlertRuleCmd extends BaseCmd {
+
+ @Inject
+ ResourceAlertService resourceAlertService;
+
+ @Parameter(name = ApiConstants.ID, type = CommandType.UUID,
+ entityType = ResourceAlertRuleResponse.class,
+ required = true,
+ description = "the ID of the alert rule to delete")
+ private Long id;
+
+ public Long getId() { return id; }
+
+ @Override
+ public long getEntityOwnerId() {
+ return CallContext.current().getCallingAccountId();
+ }
+
+ @Override
+ public void execute() throws ServerApiException {
+ try {
+ boolean result = resourceAlertService.deleteResourceAlertRule(this);
+ if (!result) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete resource alert rule");
+ }
+ SuccessResponse response = new SuccessResponse(getCommandName());
+ setResponseObject(response);
+ } catch (CloudRuntimeException e) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage());
+ }
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertRulesCmd.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertRulesCmd.java
new file mode 100644
index 000000000000..fcabe4537fef
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertRulesCmd.java
@@ -0,0 +1,72 @@
+// 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.resourcealert.api.command.user;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.BaseListAccountResourcesCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.response.ListResponse;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.ResourceAlertService;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+
+@APICommand(name = "listResourceAlertRules",
+ description = "Lists resource alert rules",
+ responseObject = ResourceAlertRuleResponse.class,
+ entityType = {ResourceAlertRule.class},
+ authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User},
+ since = "24.0.0")
+public class ListResourceAlertRulesCmd extends BaseListAccountResourcesCmd {
+
+ @Inject
+ ResourceAlertService resourceAlertService;
+
+ @Parameter(name = ApiConstants.ID, type = CommandType.UUID,
+ entityType = ResourceAlertRuleResponse.class,
+ description = "the ID of the alert rule")
+ private Long id;
+
+ @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING,
+ description = "filter by resource type: VirtualMachine, Volume, Host, StoragePool")
+ private String resourceType;
+
+ @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING,
+ description = "filter by UUID of a specific resource; requires resourcetype")
+ private String resourceId;
+
+ @Parameter(name = ApiConstants.NAME, type = CommandType.STRING,
+ description = "filter by rule name")
+ private String name;
+
+ public Long getId() { return id; }
+ public String getResourceType() { return resourceType; }
+ public String getResourceId() { return resourceId; }
+ public String getRuleName() { return name; }
+
+ @Override
+ public void execute() throws ServerApiException {
+ ListResponse response = resourceAlertService.listResourceAlertRules(this);
+ response.setResponseName(getCommandName());
+ setResponseObject(response);
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertsCmd.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertsCmd.java
new file mode 100644
index 000000000000..83e90c88cad5
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/ListResourceAlertsCmd.java
@@ -0,0 +1,83 @@
+// 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.resourcealert.api.command.user;
+
+import java.util.Date;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.BaseListAccountResourcesCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.response.ListResponse;
+import org.apache.cloudstack.resourcealert.ResourceAlert;
+import org.apache.cloudstack.resourcealert.ResourceAlertService;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertResponse;
+
+@APICommand(name = "listResourceAlerts",
+ description = "Lists fired resource alerts",
+ responseObject = ResourceAlertResponse.class,
+ entityType = {ResourceAlert.class},
+ authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User},
+ since = "24.0.0")
+public class ListResourceAlertsCmd extends BaseListAccountResourcesCmd {
+
+ @Inject
+ ResourceAlertService resourceAlertService;
+
+ @Parameter(name = ApiConstants.ALERT_RULE_ID, type = CommandType.STRING,
+ description = "UUID of the alert rule to filter by")
+ private String alertRuleId;
+
+ @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING,
+ description = "filter by resource type: VirtualMachine, Volume, Host, StoragePool")
+ private String resourceType;
+
+ @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING,
+ description = "filter by UUID of the resource that triggered the alert; requires resourcetype")
+ private String resourceId;
+
+ @Parameter(name = ApiConstants.SEVERITY, type = CommandType.STRING,
+ description = "filter by severity: CRITICAL, HIGH, MEDIUM, LOW")
+ private String severity;
+
+ @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE,
+ description = "filter alerts fired on or after this date")
+ private Date startDate;
+
+ @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE,
+ description = "filter alerts fired on or before this date")
+ private Date endDate;
+
+ public String getAlertRuleId() { return alertRuleId; }
+ public String getResourceType() { return resourceType; }
+ public String getResourceId() { return resourceId; }
+ public String getSeverity() { return severity; }
+ public Date getStartDate() { return startDate; }
+ public Date getEndDate() { return endDate; }
+
+ @Override
+ public void execute() throws ServerApiException {
+ ListResponse response = resourceAlertService.listResourceAlerts(this);
+ response.setResponseName(getCommandName());
+ setResponseObject(response);
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/UpdateResourceAlertRuleCmd.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/UpdateResourceAlertRuleCmd.java
new file mode 100644
index 000000000000..3953f7a08eb8
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/UpdateResourceAlertRuleCmd.java
@@ -0,0 +1,120 @@
+// 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.resourcealert.api.command.user;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.ResourceAlertService;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+
+import com.cloud.utils.exception.CloudRuntimeException;
+
+@APICommand(name = "updateResourceAlertRule",
+ description = "Updates a resource alert rule",
+ responseObject = ResourceAlertRuleResponse.class,
+ entityType = {ResourceAlertRule.class},
+ authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User},
+ since = "24.0.0")
+public class UpdateResourceAlertRuleCmd extends BaseCmd {
+
+ @Inject
+ ResourceAlertService resourceAlertService;
+
+ @Parameter(name = ApiConstants.ID, type = CommandType.UUID,
+ entityType = ResourceAlertRuleResponse.class,
+ required = true,
+ description = "the ID of the alert rule to update")
+ private Long id;
+
+ @Parameter(name = ApiConstants.NAME, type = CommandType.STRING,
+ description = "new name for the rule")
+ private String name;
+
+ @Parameter(name = ApiConstants.CONDITION, type = CommandType.STRING,
+ description = "new comparison operator: GT, GTE, LT, LTE, EQ")
+ private String condition;
+
+ @Parameter(name = ApiConstants.THRESHOLD, type = CommandType.DOUBLE,
+ description = "new threshold value")
+ private Double threshold;
+
+ @Parameter(name = ApiConstants.SEVERITY, type = CommandType.STRING,
+ description = "new severity: CRITICAL, HIGH, MEDIUM, LOW")
+ private String severity;
+
+ @Parameter(name = ApiConstants.MESSAGE, type = CommandType.STRING,
+ description = "new alert message")
+ private String message;
+
+ @Parameter(name = ApiConstants.EMAIL, type = CommandType.BOOLEAN,
+ description = "enable or disable email notification")
+ private Boolean email;
+
+ @Parameter(name = ApiConstants.RESET_INTERVAL, type = CommandType.INTEGER,
+ description = "new minimum seconds between repeat firings")
+ private Integer resetInterval;
+
+ @Parameter(name = ApiConstants.WEBHOOK_IDS, type = CommandType.LIST, collectionType = CommandType.STRING,
+ description = "UUIDs of webhooks to deliver alerts of this rule to; replaces the current list")
+ private List webhookIds;
+
+ @Parameter(name = ApiConstants.CLEANUP_WEBHOOKS, type = CommandType.BOOLEAN,
+ description = "true to stop delivering alerts of this rule to any webhook")
+ private Boolean cleanupWebhooks;
+
+ public Long getId() { return id; }
+ public String getName() { return name; }
+ public String getCondition() { return condition; }
+ public Double getThreshold() { return threshold; }
+ public String getSeverity() { return severity; }
+ public String getMessage() { return message; }
+ public Boolean getEmail() { return email; }
+ public Integer getResetInterval() { return resetInterval; }
+ public List getWebhookIds() { return webhookIds; }
+ public boolean isCleanupWebhooks() { return Boolean.TRUE.equals(cleanupWebhooks); }
+
+ @Override
+ public long getEntityOwnerId() {
+ return CallContext.current().getCallingAccountId();
+ }
+
+ @Override
+ public void execute() throws ServerApiException {
+ try {
+ ResourceAlertRuleResponse response = resourceAlertService.updateResourceAlertRule(this);
+ if (response == null) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update resource alert rule");
+ }
+ response.setResponseName(getCommandName());
+ setResponseObject(response);
+ } catch (CloudRuntimeException e) {
+ throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage());
+ }
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertResponse.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertResponse.java
new file mode 100644
index 000000000000..9db91ac9b641
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertResponse.java
@@ -0,0 +1,88 @@
+// 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.resourcealert.api.response;
+
+import java.util.Date;
+
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.BaseResponse;
+import org.apache.cloudstack.api.EntityReference;
+import org.apache.cloudstack.resourcealert.ResourceAlert;
+
+import com.cloud.serializer.Param;
+import com.google.gson.annotations.SerializedName;
+
+@EntityReference(value = {ResourceAlert.class})
+public class ResourceAlertResponse extends BaseResponse {
+
+ @SerializedName(ApiConstants.ID)
+ @Param(description = "the ID of the fired alert")
+ private String id;
+
+ @SerializedName(ApiConstants.ALERT_RULE_ID)
+ @Param(description = "the ID of the rule that triggered this alert")
+ private String alertRuleId;
+
+ @SerializedName(ApiConstants.RESOURCE_ID)
+ @Param(description = "the ID of the resource that triggered this alert")
+ private String resourceId;
+
+ @SerializedName(ApiConstants.RESOURCE_NAME)
+ @Param(description = "name of the resource that triggered the alert")
+ private String resourceName;
+
+ @SerializedName(ApiConstants.RESOURCE_TYPE)
+ @Param(description = "type of the resource that triggered the alert")
+ private String resourceType;
+
+ @SerializedName("alertrulename")
+ @Param(description = "name of the alert rule")
+ private String alertRuleName;
+
+ @SerializedName("metrictype")
+ @Param(description = "the metric that crossed the threshold")
+ private String metricType;
+
+ @SerializedName("metricvalue")
+ @Param(description = "the observed metric value at the time of firing")
+ private double metricValue;
+
+ @SerializedName(ApiConstants.SEVERITY)
+ @Param(description = "the severity of the alert")
+ private String severity;
+
+ @SerializedName(ApiConstants.MESSAGE)
+ @Param(description = "the alert message")
+ private String message;
+
+ @SerializedName("alerttimestamp")
+ @Param(description = "the time the alert was fired")
+ private Date alertTimestamp;
+
+ public void setId(String id) { this.id = id; }
+ public void setAlertRuleId(String alertRuleId) { this.alertRuleId = alertRuleId; }
+ public void setResourceId(String resourceId) { this.resourceId = resourceId; }
+ public void setResourceName(String resourceName) { this.resourceName = resourceName; }
+ public void setResourceType(String resourceType) { this.resourceType = resourceType; }
+ public void setAlertRuleName(String alertRuleName) { this.alertRuleName = alertRuleName; }
+ public void setMetricType(String metricType) { this.metricType = metricType; }
+ public void setMetricValue(double metricValue) { this.metricValue = metricValue; }
+ public void setSeverity(String severity) { this.severity = severity; }
+ public void setMessage(String message) { this.message = message; }
+ public void setAlertTimestamp(Date alertTimestamp) { this.alertTimestamp = alertTimestamp; }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertRuleResponse.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertRuleResponse.java
new file mode 100644
index 000000000000..98ed1a2ffbc5
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertRuleResponse.java
@@ -0,0 +1,119 @@
+// 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.resourcealert.api.response;
+
+import java.util.Date;
+import java.util.List;
+
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.BaseResponse;
+import org.apache.cloudstack.api.EntityReference;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+
+import com.cloud.serializer.Param;
+import com.google.gson.annotations.SerializedName;
+
+@EntityReference(value = {ResourceAlertRule.class})
+public class ResourceAlertRuleResponse extends BaseResponse {
+
+ @SerializedName(ApiConstants.ID)
+ @Param(description = "the ID of the alert rule")
+ private String id;
+
+ @SerializedName(ApiConstants.NAME)
+ @Param(description = "the name of the alert rule")
+ private String name;
+
+ @SerializedName(ApiConstants.RESOURCE_TYPE)
+ @Param(description = "the type of resource this rule monitors")
+ private String resourceType;
+
+ @SerializedName(ApiConstants.RESOURCE_ID)
+ @Param(description = "the specific resource ID; absent for generic rules")
+ private String resourceId;
+
+ @SerializedName(ApiConstants.RESOURCE_NAME)
+ @Param(description = "name of the resource the rule watches")
+ private String resourceName;
+
+ @SerializedName(ApiConstants.METRIC)
+ @Param(description = "the metric being monitored")
+ private String metric;
+
+ @SerializedName(ApiConstants.CONDITION)
+ @Param(description = "the comparison operator (GT, GTE, LT, LTE, EQ)")
+ private String condition;
+
+ @SerializedName(ApiConstants.THRESHOLD)
+ @Param(description = "the threshold value that triggers this rule")
+ private double threshold;
+
+ @SerializedName(ApiConstants.SEVERITY)
+ @Param(description = "the severity of the alert (CRITICAL, HIGH, MEDIUM, LOW)")
+ private String severity;
+
+ @SerializedName(ApiConstants.MESSAGE)
+ @Param(description = "the message sent with the alert")
+ private String message;
+
+ @SerializedName(ApiConstants.EMAIL)
+ @Param(description = "whether email notification is enabled for this rule")
+ private boolean email;
+
+ @SerializedName(ApiConstants.RESET_INTERVAL)
+ @Param(description = "minimum seconds between repeat firings of this rule")
+ private int resetInterval;
+
+ @SerializedName(ApiConstants.WEBHOOK_IDS)
+ @Param(description = "UUIDs of webhooks the rule delivers alerts to")
+ private List webhookIds;
+
+ @SerializedName(ApiConstants.ACCOUNT)
+ @Param(description = "the account that owns this rule")
+ private String accountName;
+
+ @SerializedName(ApiConstants.DOMAIN_ID)
+ @Param(description = "the ID of the domain this rule belongs to")
+ private String domainId;
+
+ @SerializedName(ApiConstants.DOMAIN)
+ @Param(description = "the name of the domain this rule belongs to")
+ private String domainName;
+
+ @SerializedName(ApiConstants.CREATED)
+ @Param(description = "the date this rule was created")
+ private Date created;
+
+ public void setId(String id) { this.id = id; }
+ public void setName(String name) { this.name = name; }
+ public void setResourceType(String resourceType) { this.resourceType = resourceType; }
+ public void setResourceId(String resourceId) { this.resourceId = resourceId; }
+ public void setResourceName(String resourceName) { this.resourceName = resourceName; }
+ public void setMetric(String metric) { this.metric = metric; }
+ public void setCondition(String condition) { this.condition = condition; }
+ public void setThreshold(double threshold) { this.threshold = threshold; }
+ public void setSeverity(String severity) { this.severity = severity; }
+ public void setMessage(String message) { this.message = message; }
+ public void setEmail(boolean email) { this.email = email; }
+ public void setResetInterval(int resetInterval) { this.resetInterval = resetInterval; }
+ public void setWebhookIds(List webhookIds) { this.webhookIds = webhookIds; }
+ public void setAccountName(String accountName) { this.accountName = accountName; }
+ public void setDomainId(String domainId) { this.domainId = domainId; }
+ public void setDomainName(String domainName) { this.domainName = domainName; }
+ public void setCreated(Date created) { this.created = created; }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDao.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDao.java
new file mode 100644
index 000000000000..c6e18eef0819
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDao.java
@@ -0,0 +1,39 @@
+// 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.resourcealert.dao;
+
+import java.util.Date;
+import java.util.List;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+
+import com.cloud.utils.Pair;
+import com.cloud.utils.db.GenericDao;
+
+public interface ResourceAlertDao extends GenericDao {
+
+ List listByAlertRuleId(long alertRuleId);
+
+ // Returns the most recent firing of a rule for a specific resource; used for reset-interval enforcement.
+ ResourceAlertVO findLastFiredForRule(long alertRuleId, Long resourceId);
+
+ Pair, Integer> searchAndCountByFilters(List alertRuleIds, Long resourceId, String severity,
+ Date startDate, Date endDate, Long startIndex, Long pageSize);
+
+ int removeOlderThan(Date date);
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDaoImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDaoImpl.java
new file mode 100644
index 000000000000..0ecb2185f008
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDaoImpl.java
@@ -0,0 +1,106 @@
+// 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.resourcealert.dao;
+
+import java.util.Date;
+import java.util.List;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+import org.apache.commons.lang3.StringUtils;
+
+import com.cloud.utils.Pair;
+import com.cloud.utils.db.Filter;
+import com.cloud.utils.db.GenericDaoBase;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+
+public class ResourceAlertDaoImpl extends GenericDaoBase implements ResourceAlertDao {
+
+ private final SearchBuilder alertRuleIdSearch;
+ private final SearchBuilder olderThanSearch;
+
+ public ResourceAlertDaoImpl() {
+ alertRuleIdSearch = createSearchBuilder();
+ alertRuleIdSearch.and("alertRuleId", alertRuleIdSearch.entity().getAlertRuleId(), SearchCriteria.Op.EQ);
+ alertRuleIdSearch.done();
+
+ olderThanSearch = createSearchBuilder();
+ olderThanSearch.and("alertTimestamp", olderThanSearch.entity().getAlertTimestamp(), SearchCriteria.Op.LT);
+ olderThanSearch.done();
+ }
+
+ @Override
+ public int removeOlderThan(Date date) {
+ SearchCriteria sc = olderThanSearch.create();
+ sc.setParameters("alertTimestamp", date);
+ return expunge(sc);
+ }
+
+ @Override
+ public List listByAlertRuleId(long alertRuleId) {
+ SearchCriteria sc = alertRuleIdSearch.create();
+ sc.setParameters("alertRuleId", alertRuleId);
+ return listBy(sc);
+ }
+
+ @Override
+ public ResourceAlertVO findLastFiredForRule(long alertRuleId, Long resourceId) {
+ SearchBuilder sb = createSearchBuilder();
+ sb.and("alertRuleId", sb.entity().getAlertRuleId(), SearchCriteria.Op.EQ);
+ if (resourceId != null) {
+ sb.and("resourceId", sb.entity().getResourceId(), SearchCriteria.Op.EQ);
+ }
+ Filter filter = new Filter(ResourceAlertVO.class, "alertTimestamp", false, 0L, 1L);
+ SearchCriteria sc = sb.create();
+ sc.setParameters("alertRuleId", alertRuleId);
+ if (resourceId != null) {
+ sc.setParameters("resourceId", resourceId);
+ }
+ List results = listBy(sc, filter);
+ return results.isEmpty() ? null : results.get(0);
+ }
+
+ @Override
+ public Pair, Integer> searchAndCountByFilters(List alertRuleIds, Long resourceId, String severity,
+ Date startDate, Date endDate, Long startIndex, Long pageSize) {
+ SearchBuilder sb = createSearchBuilder();
+ if (alertRuleIds != null) {
+ sb.and("alertRuleIds", sb.entity().getAlertRuleId(), SearchCriteria.Op.IN);
+ }
+ if (resourceId != null) {
+ sb.and("resourceId", sb.entity().getResourceId(), SearchCriteria.Op.EQ);
+ }
+ if (StringUtils.isNotBlank(severity)) {
+ sb.and("severity", sb.entity().getSeverity(), SearchCriteria.Op.EQ);
+ }
+ if (startDate != null) {
+ sb.and("startDate", sb.entity().getAlertTimestamp(), SearchCriteria.Op.GTEQ);
+ }
+ if (endDate != null) {
+ sb.and("endDate", sb.entity().getAlertTimestamp(), SearchCriteria.Op.LTEQ);
+ }
+ SearchCriteria sc = sb.create();
+ if (alertRuleIds != null) sc.setParameters("alertRuleIds", alertRuleIds.toArray());
+ if (resourceId != null) sc.setParameters("resourceId", resourceId);
+ if (StringUtils.isNotBlank(severity)) sc.setParameters("severity", severity);
+ if (startDate != null) sc.setParameters("startDate", startDate);
+ if (endDate != null) sc.setParameters("endDate", endDate);
+ Filter filter = new Filter(ResourceAlertVO.class, "alertTimestamp", false, startIndex, pageSize);
+ return searchAndCount(sc, filter);
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDao.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDao.java
new file mode 100644
index 000000000000..e9e4135306e5
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDao.java
@@ -0,0 +1,42 @@
+// 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.resourcealert.dao;
+
+import java.util.List;
+
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+
+import com.cloud.utils.db.GenericDao;
+
+public interface ResourceAlertRuleDao extends GenericDao {
+
+ ResourceAlertRuleVO findByUuid(String uuid);
+
+ List listActive();
+
+ List listByAccountId(long accountId);
+
+ List listByResourceTypeAndId(ResourceAlertRule.ResourceType resourceType, Long resourceId);
+
+ int countActiveByAccountId(long accountId);
+
+ List listIdsByResourceType(ResourceAlertRule.ResourceType resourceType);
+
+ boolean existsSpecificRule(ResourceAlertRule.ResourceType resourceType, String metric, long resourceId);
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDaoImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDaoImpl.java
new file mode 100644
index 000000000000..3b67449f0ed1
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDaoImpl.java
@@ -0,0 +1,126 @@
+// 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.resourcealert.dao;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+
+import com.cloud.utils.db.GenericDaoBase;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+
+public class ResourceAlertRuleDaoImpl extends GenericDaoBase implements ResourceAlertRuleDao {
+
+ private final SearchBuilder activeSearch;
+ private final SearchBuilder accountIdSearch;
+ private final SearchBuilder resourceTypeAndIdSearch;
+ private final SearchBuilder activeByAccountSearch;
+ private final SearchBuilder specificRuleSearch;
+ private final SearchBuilder resourceTypeSearch;
+
+ public ResourceAlertRuleDaoImpl() {
+ activeSearch = createSearchBuilder();
+ activeSearch.and("removed", activeSearch.entity().getRemoved(), SearchCriteria.Op.NULL);
+ activeSearch.done();
+
+ accountIdSearch = createSearchBuilder();
+ accountIdSearch.and("accountId", accountIdSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
+ accountIdSearch.done();
+
+ resourceTypeAndIdSearch = createSearchBuilder();
+ resourceTypeAndIdSearch.and("resourceType", resourceTypeAndIdSearch.entity().getResourceType(), SearchCriteria.Op.EQ);
+ resourceTypeAndIdSearch.and("resourceId", resourceTypeAndIdSearch.entity().getResourceId(), SearchCriteria.Op.EQ);
+ resourceTypeAndIdSearch.done();
+
+ activeByAccountSearch = createSearchBuilder();
+ activeByAccountSearch.and("accountId", activeByAccountSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
+ activeByAccountSearch.and("removed", activeByAccountSearch.entity().getRemoved(), SearchCriteria.Op.NULL);
+ activeByAccountSearch.done();
+
+ specificRuleSearch = createSearchBuilder();
+ specificRuleSearch.and("resourceType", specificRuleSearch.entity().getResourceType(), SearchCriteria.Op.EQ);
+ specificRuleSearch.and("metric", specificRuleSearch.entity().getMetric(), SearchCriteria.Op.EQ);
+ specificRuleSearch.and("resourceId", specificRuleSearch.entity().getResourceId(), SearchCriteria.Op.EQ);
+ specificRuleSearch.and("removed", specificRuleSearch.entity().getRemoved(), SearchCriteria.Op.NULL);
+ specificRuleSearch.done();
+
+ resourceTypeSearch = createSearchBuilder();
+ resourceTypeSearch.and("resourceType", resourceTypeSearch.entity().getResourceType(), SearchCriteria.Op.EQ);
+ resourceTypeSearch.done();
+ }
+
+ @Override
+ public List listActive() {
+ SearchCriteria sc = activeSearch.create();
+ return listBy(sc);
+ }
+
+ @Override
+ public ResourceAlertRuleVO findByUuid(String uuid) {
+ SearchBuilder sb = createSearchBuilder();
+ sb.and("uuid", sb.entity().getUuid(), SearchCriteria.Op.EQ);
+ SearchCriteria sc = sb.create();
+ sc.setParameters("uuid", uuid);
+ return findOneBy(sc);
+ }
+
+ @Override
+ public List listByAccountId(long accountId) {
+ SearchCriteria sc = accountIdSearch.create();
+ sc.setParameters("accountId", accountId);
+ return listBy(sc);
+ }
+
+ @Override
+ public List listByResourceTypeAndId(ResourceAlertRule.ResourceType resourceType, Long resourceId) {
+ SearchCriteria sc = resourceTypeAndIdSearch.create();
+ sc.setParameters("resourceType", resourceType);
+ if (resourceId != null) {
+ sc.setParameters("resourceId", resourceId);
+ } else {
+ sc.setParameters("resourceId", (Object) null);
+ }
+ return listBy(sc);
+ }
+
+ @Override
+ public int countActiveByAccountId(long accountId) {
+ SearchCriteria sc = activeByAccountSearch.create();
+ sc.setParameters("accountId", accountId);
+ return getCount(sc);
+ }
+
+ @Override
+ public boolean existsSpecificRule(ResourceAlertRule.ResourceType resourceType, String metric, long resourceId) {
+ SearchCriteria sc = specificRuleSearch.create();
+ sc.setParameters("resourceType", resourceType);
+ sc.setParameters("metric", metric);
+ sc.setParameters("resourceId", resourceId);
+ return getCount(sc) > 0;
+ }
+
+ @Override
+ public List listIdsByResourceType(ResourceAlertRule.ResourceType resourceType) {
+ SearchCriteria sc = resourceTypeSearch.create();
+ sc.setParameters("resourceType", resourceType);
+ return listIncludingRemovedBy(sc).stream().map(ResourceAlertRuleVO::getId).collect(Collectors.toList());
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDao.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDao.java
new file mode 100644
index 000000000000..acdeda34e06b
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDao.java
@@ -0,0 +1,27 @@
+// 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.resourcealert.dao;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleJoinVO;
+
+import com.cloud.utils.db.GenericDao;
+
+public interface ResourceAlertRuleJoinDao extends GenericDao {
+
+ ResourceAlertRuleJoinVO findByUuid(String uuid);
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDaoImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDaoImpl.java
new file mode 100644
index 000000000000..40895284ae9f
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleJoinDaoImpl.java
@@ -0,0 +1,36 @@
+// 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.resourcealert.dao;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleJoinVO;
+
+import com.cloud.utils.db.GenericDaoBase;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+
+public class ResourceAlertRuleJoinDaoImpl extends GenericDaoBase implements ResourceAlertRuleJoinDao {
+
+ @Override
+ public ResourceAlertRuleJoinVO findByUuid(String uuid) {
+ SearchBuilder sb = createSearchBuilder();
+ sb.and("uuid", sb.entity().getUuid(), SearchCriteria.Op.EQ);
+ SearchCriteria sc = sb.create();
+ sc.setParameters("uuid", uuid);
+ return findOneBy(sc);
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDao.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDao.java
new file mode 100644
index 000000000000..72388bd28c60
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDao.java
@@ -0,0 +1,31 @@
+// 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.resourcealert.dao;
+
+import java.util.List;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleWebhookVO;
+
+import com.cloud.utils.db.GenericDao;
+
+public interface ResourceAlertRuleWebhookDao extends GenericDao {
+
+ List listWebhookIdsByRule(long ruleId);
+
+ void replaceWebhooksForRule(long ruleId, List webhookIds);
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDaoImpl.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDaoImpl.java
new file mode 100644
index 000000000000..4ba0888a47a8
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDaoImpl.java
@@ -0,0 +1,64 @@
+// 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.resourcealert.dao;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleWebhookVO;
+
+import com.cloud.utils.db.GenericDaoBase;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+import com.cloud.utils.db.Transaction;
+import com.cloud.utils.db.TransactionCallbackNoReturn;
+import com.cloud.utils.db.TransactionStatus;
+
+public class ResourceAlertRuleWebhookDaoImpl extends GenericDaoBase
+ implements ResourceAlertRuleWebhookDao {
+
+ private final SearchBuilder ruleSearch;
+
+ public ResourceAlertRuleWebhookDaoImpl() {
+ ruleSearch = createSearchBuilder();
+ ruleSearch.and("ruleId", ruleSearch.entity().getRuleId(), SearchCriteria.Op.EQ);
+ ruleSearch.done();
+ }
+
+ @Override
+ public List listWebhookIdsByRule(long ruleId) {
+ SearchCriteria sc = ruleSearch.create();
+ sc.setParameters("ruleId", ruleId);
+ return listBy(sc).stream().map(ResourceAlertRuleWebhookVO::getWebhookId).collect(Collectors.toList());
+ }
+
+ @Override
+ public void replaceWebhooksForRule(long ruleId, List webhookIds) {
+ Transaction.execute(new TransactionCallbackNoReturn() {
+ @Override
+ public void doInTransactionWithoutResult(TransactionStatus status) {
+ SearchCriteria sc = ruleSearch.create();
+ sc.setParameters("ruleId", ruleId);
+ expunge(sc);
+ for (Long webhookId : webhookIds) {
+ persist(new ResourceAlertRuleWebhookVO(ruleId, webhookId));
+ }
+ }
+ });
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleJoinVO.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleJoinVO.java
new file mode 100644
index 000000000000..dbf7551db5a9
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleJoinVO.java
@@ -0,0 +1,147 @@
+// 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.resourcealert.vo;
+
+import java.util.Date;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+
+import org.apache.cloudstack.acl.ControlledEntity;
+import org.apache.cloudstack.resourcealert.AlertCondition;
+import org.apache.cloudstack.resourcealert.AlertSeverity;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+
+import com.cloud.user.Account;
+
+@Entity
+@Table(name = "resource_alert_rule_view")
+public class ResourceAlertRuleJoinVO implements ControlledEntity {
+
+ @Id
+ @Column(name = "id", updatable = false, nullable = false)
+ private long id;
+
+ @Column(name = "uuid")
+ private String uuid;
+
+ @Column(name = "name")
+ private String name;
+
+ @Column(name = "resource_type")
+ @Enumerated(value = EnumType.STRING)
+ private ResourceAlertRule.ResourceType resourceType;
+
+ @Column(name = "resource_id")
+ private Long resourceId;
+
+ @Column(name = "metric")
+ private String metric;
+
+ @Column(name = "condition_operator")
+ @Enumerated(value = EnumType.STRING)
+ private AlertCondition condition;
+
+ @Column(name = "threshold")
+ private double threshold;
+
+ @Column(name = "severity")
+ @Enumerated(value = EnumType.STRING)
+ private AlertSeverity severity;
+
+ @Column(name = "message", length = 4096)
+ private String message;
+
+ @Column(name = "email")
+ private boolean email;
+
+ @Column(name = "reset_interval")
+ private int resetInterval;
+
+ @Column(name = "created")
+ private Date created;
+
+ @Column(name = "updated")
+ @Temporal(value = TemporalType.TIMESTAMP)
+ private Date updated;
+
+ @Column(name = "removed")
+ private Date removed;
+
+ @Column(name = "account_id")
+ private long accountId;
+
+ @Column(name = "account_uuid")
+ private String accountUuid;
+
+ @Column(name = "account_name")
+ private String accountName;
+
+ @Column(name = "account_type")
+ @Enumerated(value = EnumType.STRING)
+ private Account.Type accountType;
+
+ @Column(name = "domain_id")
+ private long domainId;
+
+ @Column(name = "domain_uuid")
+ private String domainUuid;
+
+ @Column(name = "domain_name")
+ private String domainName;
+
+ @Column(name = "domain_path")
+ private String domainPath;
+
+ public ResourceAlertRuleJoinVO() {}
+
+ public long getId() { return id; }
+ public String getUuid() { return uuid; }
+ public String getName() { return name; }
+ public ResourceAlertRule.ResourceType getResourceType() { return resourceType; }
+ public Long getResourceId() { return resourceId; }
+ public String getMetric() { return metric; }
+ public AlertCondition getCondition() { return condition; }
+ public double getThreshold() { return threshold; }
+ public AlertSeverity getSeverity() { return severity; }
+ public String getMessage() { return message; }
+ public boolean isEmail() { return email; }
+ public int getResetInterval() { return resetInterval; }
+ public Date getCreated() { return created; }
+ public Date getUpdated() { return updated; }
+ public Date getRemoved() { return removed; }
+ public long getAccountId() { return accountId; }
+ public String getAccountUuid() { return accountUuid; }
+ public String getAccountName() { return accountName; }
+ public Account.Type getAccountType() { return accountType; }
+ public long getDomainId() { return domainId; }
+ public String getDomainUuid() { return domainUuid; }
+ public String getDomainName() { return domainName; }
+ public String getDomainPath() { return domainPath; }
+
+ @Override
+ public Class> getEntityType() {
+ return ResourceAlertRule.class;
+ }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleVO.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleVO.java
new file mode 100644
index 000000000000..bafe1ba40bd8
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleVO.java
@@ -0,0 +1,156 @@
+// 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.resourcealert.vo;
+
+import java.util.Date;
+import java.util.UUID;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+
+import org.apache.cloudstack.resourcealert.AlertCondition;
+import org.apache.cloudstack.resourcealert.AlertSeverity;
+import org.apache.cloudstack.resourcealert.ResourceAlertRule;
+
+import com.cloud.utils.db.GenericDao;
+
+@Entity
+@Table(name = "resource_alert_rules")
+public class ResourceAlertRuleVO implements ResourceAlertRule {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private long id;
+
+ @Column(name = "uuid")
+ private String uuid;
+
+ @Column(name = "name")
+ private String name;
+
+ @Column(name = "resource_type")
+ @Enumerated(value = EnumType.STRING)
+ private ResourceType resourceType;
+
+ @Column(name = "resource_id")
+ private Long resourceId;
+
+ @Column(name = "account_id")
+ private long accountId;
+
+ @Column(name = "domain_id")
+ private long domainId;
+
+ @Column(name = "metric")
+ private String metric;
+
+ @Column(name = "condition_operator")
+ @Enumerated(value = EnumType.STRING)
+ private AlertCondition condition;
+
+ @Column(name = "threshold")
+ private double threshold;
+
+ @Column(name = "severity")
+ @Enumerated(value = EnumType.STRING)
+ private AlertSeverity severity;
+
+ @Column(name = "message", length = 4096)
+ private String message;
+
+ @Column(name = "email")
+ private boolean email;
+
+ @Column(name = "reset_interval")
+ private int resetInterval;
+
+ @Column(name = GenericDao.CREATED_COLUMN)
+ private Date created;
+
+ @Column(name = "updated")
+ @Temporal(value = TemporalType.TIMESTAMP)
+ private Date updated;
+
+ @Column(name = GenericDao.REMOVED_COLUMN)
+ private Date removed;
+
+ public ResourceAlertRuleVO() {
+ this.uuid = UUID.randomUUID().toString();
+ }
+
+ public ResourceAlertRuleVO(String name, ResourceType resourceType, Long resourceId,
+ long accountId, long domainId, String metric, AlertCondition condition,
+ double threshold, AlertSeverity severity, String message, boolean email, int resetInterval) {
+ this.uuid = UUID.randomUUID().toString();
+ this.name = name;
+ this.resourceType = resourceType;
+ this.resourceId = resourceId;
+ this.accountId = accountId;
+ this.domainId = domainId;
+ this.metric = metric;
+ this.condition = condition;
+ this.threshold = threshold;
+ this.severity = severity;
+ this.message = message;
+ this.email = email;
+ this.resetInterval = resetInterval;
+ }
+
+ @Override public long getId() { return id; }
+ @Override public String getUuid() { return uuid; }
+ @Override public String getName() { return name; }
+ @Override public ResourceType getResourceType() { return resourceType; }
+ @Override public Long getResourceId() { return resourceId; }
+ @Override public long getAccountId() { return accountId; }
+ @Override public long getDomainId() { return domainId; }
+ @Override public String getMetric() { return metric; }
+ @Override public AlertCondition getCondition() { return condition; }
+ @Override public double getThreshold() { return threshold; }
+ @Override public AlertSeverity getSeverity() { return severity; }
+ @Override public String getMessage() { return message; }
+ @Override public boolean isEmail() { return email; }
+ @Override public int getResetInterval() { return resetInterval; }
+ @Override public Date getCreated() { return created; }
+
+ @Override
+ public Class> getEntityType() {
+ return ResourceAlertRule.class;
+ }
+
+ public Date getRemoved() { return removed; }
+ public Date getUpdated() { return updated; }
+
+ public void setName(String name) { this.name = name; }
+ public void setCondition(AlertCondition condition) { this.condition = condition; }
+ public void setThreshold(double threshold) { this.threshold = threshold; }
+ public void setSeverity(AlertSeverity severity) { this.severity = severity; }
+ public void setMessage(String message) { this.message = message; }
+ public void setEmail(boolean email) { this.email = email; }
+ public void setResetInterval(int resetInterval) { this.resetInterval = resetInterval; }
+ public void setUpdated(Date updated) { this.updated = updated; }
+ public void setRemoved(Date removed) { this.removed = removed; }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleWebhookVO.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleWebhookVO.java
new file mode 100644
index 000000000000..4f37f4f8e4a1
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleWebhookVO.java
@@ -0,0 +1,55 @@
+// 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.resourcealert.vo;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+
+import org.apache.cloudstack.api.InternalIdentity;
+
+@Entity
+@Table(name = "resource_alert_rules_webhook")
+public class ResourceAlertRuleWebhookVO implements InternalIdentity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private long id;
+
+ @Column(name = "resource_alert_rule_id")
+ private long ruleId;
+
+ @Column(name = "webhook_id")
+ private long webhookId;
+
+ public ResourceAlertRuleWebhookVO() {}
+
+ public ResourceAlertRuleWebhookVO(long ruleId, long webhookId) {
+ this.ruleId = ruleId;
+ this.webhookId = webhookId;
+ }
+
+ @Override
+ public long getId() { return id; }
+ public long getRuleId() { return ruleId; }
+ public long getWebhookId() { return webhookId; }
+}
diff --git a/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertVO.java b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertVO.java
new file mode 100644
index 000000000000..3a2da89bc756
--- /dev/null
+++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertVO.java
@@ -0,0 +1,97 @@
+// 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.resourcealert.vo;
+
+import java.util.Date;
+import java.util.UUID;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+
+import org.apache.cloudstack.resourcealert.AlertSeverity;
+import org.apache.cloudstack.resourcealert.ResourceAlert;
+
+@Entity
+@Table(name = "resource_alerts")
+public class ResourceAlertVO implements ResourceAlert {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private long id;
+
+ @Column(name = "uuid")
+ private String uuid;
+
+ @Column(name = "alert_rule_id")
+ private long alertRuleId;
+
+ @Column(name = "resource_id")
+ private Long resourceId;
+
+ @Column(name = "metric_type")
+ private String metricType;
+
+ @Column(name = "metric_value")
+ private double metricValue;
+
+ @Column(name = "severity")
+ @Enumerated(value = EnumType.STRING)
+ private AlertSeverity severity;
+
+ @Column(name = "message", length = 4096)
+ private String message;
+
+ @Column(name = "alert_timestamp")
+ @Temporal(value = TemporalType.TIMESTAMP)
+ private Date alertTimestamp;
+
+ public ResourceAlertVO() {
+ this.uuid = UUID.randomUUID().toString();
+ }
+
+ public ResourceAlertVO(long alertRuleId, Long resourceId, String metricType,
+ double metricValue, AlertSeverity severity, String message, Date alertTimestamp) {
+ this.uuid = UUID.randomUUID().toString();
+ this.alertRuleId = alertRuleId;
+ this.resourceId = resourceId;
+ this.metricType = metricType;
+ this.metricValue = metricValue;
+ this.severity = severity;
+ this.message = message;
+ this.alertTimestamp = alertTimestamp;
+ }
+
+ @Override public long getId() { return id; }
+ @Override public String getUuid() { return uuid; }
+ @Override public long getAlertRuleId() { return alertRuleId; }
+ @Override public Long getResourceId() { return resourceId; }
+ @Override public String getMetricType() { return metricType; }
+ @Override public double getMetricValue() { return metricValue; }
+ @Override public AlertSeverity getSeverity() { return severity; }
+ @Override public String getMessage() { return message; }
+ @Override public Date getAlertTimestamp() { return alertTimestamp; }
+}
diff --git a/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/module.properties b/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/module.properties
new file mode 100644
index 000000000000..28f110acb319
--- /dev/null
+++ b/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/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=resource-alerts
+parent=api
diff --git a/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/spring-resource-alerts-context.xml b/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/spring-resource-alerts-context.xml
new file mode 100644
index 000000000000..237ba7038edb
--- /dev/null
+++ b/plugins/resource-alerts/src/main/resources/META-INF/cloudstack/resource-alerts/spring-resource-alerts-context.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/AlertConditionTest.java b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/AlertConditionTest.java
new file mode 100644
index 000000000000..2a34ba71ea3b
--- /dev/null
+++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/AlertConditionTest.java
@@ -0,0 +1,96 @@
+// 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.resourcealert;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class AlertConditionTest {
+
+ @Test
+ public void testGtFiresAbove() {
+ assertTrue(AlertCondition.GT.evaluate(81.0, 80.0));
+ }
+
+ @Test
+ public void testGtSilentAtBoundary() {
+ assertFalse(AlertCondition.GT.evaluate(80.0, 80.0));
+ }
+
+ @Test
+ public void testGtSilentBelow() {
+ assertFalse(AlertCondition.GT.evaluate(79.0, 80.0));
+ }
+
+ @Test
+ public void testGteFiresAbove() {
+ assertTrue(AlertCondition.GTE.evaluate(81.0, 80.0));
+ }
+
+ @Test
+ public void testGteFiresAtBoundary() {
+ assertTrue(AlertCondition.GTE.evaluate(80.0, 80.0));
+ }
+
+ @Test
+ public void testGteSilentBelow() {
+ assertFalse(AlertCondition.GTE.evaluate(79.0, 80.0));
+ }
+
+ @Test
+ public void testLtFiresBelow() {
+ assertTrue(AlertCondition.LT.evaluate(10.0, 20.0));
+ }
+
+ @Test
+ public void testLtSilentAtBoundary() {
+ assertFalse(AlertCondition.LT.evaluate(20.0, 20.0));
+ }
+
+ @Test
+ public void testLtSilentAbove() {
+ assertFalse(AlertCondition.LT.evaluate(21.0, 20.0));
+ }
+
+ @Test
+ public void testLteFiresAtBoundary() {
+ assertTrue(AlertCondition.LTE.evaluate(20.0, 20.0));
+ }
+
+ @Test
+ public void testLteFiresBelow() {
+ assertTrue(AlertCondition.LTE.evaluate(19.0, 20.0));
+ }
+
+ @Test
+ public void testLteSilentAbove() {
+ assertFalse(AlertCondition.LTE.evaluate(21.0, 20.0));
+ }
+
+ @Test
+ public void testEqFiresOnExactMatch() {
+ assertTrue(AlertCondition.EQ.evaluate(75.0, 75.0));
+ }
+
+ @Test
+ public void testEqSilentOnMismatch() {
+ assertFalse(AlertCondition.EQ.evaluate(75.001, 75.0));
+ }
+}
diff --git a/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImplTest.java b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImplTest.java
new file mode 100644
index 000000000000..f9141465901a
--- /dev/null
+++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImplTest.java
@@ -0,0 +1,883 @@
+// 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.resourcealert;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.cloudstack.framework.config.dao.ConfigurationDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleWebhookDao;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.utils.identity.ManagementServerNode;
+import org.apache.cloudstack.utils.mailing.SMTPMailProperties;
+import org.apache.cloudstack.utils.mailing.SMTPMailSender;
+import org.apache.cloudstack.webhook.WebhookHelper;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.cluster.ManagementServerHostVO;
+import com.cloud.cluster.dao.ManagementServerHostDao;
+import com.cloud.domain.dao.DomainDao;
+import com.cloud.host.HostStats;
+import com.cloud.host.HostVO;
+import com.cloud.host.dao.HostDao;
+import com.cloud.server.ResourceTag;
+import com.cloud.server.StatsCollector;
+import com.cloud.storage.Storage;
+import com.cloud.storage.StorageStats;
+import com.cloud.storage.Volume;
+import com.cloud.storage.VolumeStats;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.tags.dao.ResourceTagDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountVO;
+import com.cloud.user.dao.AccountDao;
+import com.cloud.utils.Pair;
+import com.cloud.vm.UserVmVO;
+import com.cloud.vm.VirtualMachine;
+import com.cloud.vm.VmStats;
+import com.cloud.vm.dao.UserVmDao;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ResourceAlertManagerImplTest {
+
+ @Spy @InjectMocks
+ ResourceAlertManagerImpl manager;
+
+ @Mock ResourceAlertRuleDao ruleDao;
+ @Mock ResourceAlertDao alertDao;
+ @Mock ResourceAlertRuleWebhookDao ruleWebhookDao;
+ @Mock WebhookHelper webhookHelper;
+ @Mock UserVmDao userVmDao;
+ @Mock HostDao hostDao;
+ @Mock PrimaryDataStoreDao storagePoolDao;
+ @Mock VolumeDao volumeDao;
+ @Mock StatsCollector statsCollector;
+ @Mock ConfigurationDao configDao;
+ @Mock ResourceTagDao resourceTagDao;
+ @Mock AccountDao accountDao;
+ @Mock DomainDao domainDao;
+ @Mock ManagementServerHostDao managementServerHostDao;
+ @Mock SMTPMailSender mailSender;
+
+ @Captor ArgumentCaptor alertCaptor;
+ @Captor ArgumentCaptor mailCaptor;
+
+ private static final long VM_ID = 101L;
+ private static final long HOST_ID = 201L;
+ private static final long POOL_ID = 301L;
+
+ @Before
+ public void setUp() throws Exception {
+ // stub out the AlertGenerator static call (needs Spring context in real env)
+ doNothing().when(manager).publishAlertEvent(anyLong(), anyString(), anyString());
+ // owners and resources exist unless a test says otherwise
+ AccountVO defaultOwner = mock(AccountVO.class);
+ lenient().when(defaultOwner.getId()).thenReturn(1L);
+ lenient().when(defaultOwner.getType()).thenReturn(Account.Type.NORMAL);
+ lenient().when(accountDao.findById(anyLong())).thenReturn(defaultOwner);
+ lenient().when(userVmDao.findById(anyLong())).thenReturn(mock(UserVmVO.class));
+ lenient().when(volumeDao.findById(anyLong())).thenReturn(mock(VolumeVO.class));
+ lenient().when(hostDao.findById(anyLong())).thenReturn(mock(HostVO.class));
+ lenient().when(storagePoolDao.findById(anyLong())).thenReturn(mock(StoragePoolVO.class));
+ }
+
+ private ResourceAlertRuleVO vmCpuRule(Long resourceId) {
+ return vmCpuRuleWithEmail(resourceId, false);
+ }
+
+ private ResourceAlertRuleVO vmCpuRuleWithEmail(Long resourceId, boolean email) {
+ return new ResourceAlertRuleVO("test", ResourceAlertRule.ResourceType.VirtualMachine,
+ resourceId, 1L, 1L, "CPU_UTILIZATION", AlertCondition.GT, 80.0,
+ AlertSeverity.HIGH, "CPU high", email, 600);
+ }
+
+ private void injectMailSender(String... recipients) throws Exception {
+ Field f = ResourceAlertManagerImpl.class.getDeclaredField("mailSender");
+ f.setAccessible(true);
+ f.set(manager, mailSender);
+
+ Field r = ResourceAlertManagerImpl.class.getDeclaredField("emailRecipients");
+ r.setAccessible(true);
+ r.set(manager, recipients);
+
+ Field s = ResourceAlertManagerImpl.class.getDeclaredField("senderAddress");
+ s.setAccessible(true);
+ s.set(manager, "alerts@example.com");
+
+ // replace async executor with a synchronous one so verify() works immediately
+ manager.emailExecutor = new AbstractExecutorService() {
+ @Override public void execute(Runnable command) { command.run(); }
+ @Override public void shutdown() {}
+ @Override public List shutdownNow() { return Collections.emptyList(); }
+ @Override public boolean isShutdown() { return false; }
+ @Override public boolean isTerminated() { return false; }
+ @Override public boolean awaitTermination(long t, TimeUnit u) { return true; }
+ };
+ }
+
+ @Test
+ public void testVmCpuRuleFiresWhenThresholdBreached() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ ResourceAlertVO fired = alertCaptor.getValue();
+ assertEquals(VM_ID, (long) fired.getResourceId());
+ assertEquals("CPU_UTILIZATION", fired.getMetricType());
+ assertEquals(85.0, fired.getMetricValue(), 0.001);
+ assertEquals(AlertSeverity.HIGH, fired.getSeverity());
+ }
+
+ @Test
+ public void testVmCpuRuleDoesNotFireWhenBelowThreshold() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(75.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testRuleDoesNotFireWithinResetInterval() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+
+ ResourceAlertVO recentAlert = mock(ResourceAlertVO.class);
+ when(recentAlert.getAlertTimestamp()).thenReturn(new Date(System.currentTimeMillis() - 10_000L));
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(recentAlert);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testRuleFiresAfterResetIntervalExpires() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+
+ ResourceAlertVO oldAlert = mock(ResourceAlertVO.class);
+ when(oldAlert.getAlertTimestamp()).thenReturn(new Date(System.currentTimeMillis() - 700_000L));
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(oldAlert);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(any());
+ }
+
+ @Test
+ public void testNullStatsSkipped() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testVmMemorySkippedWhenNoBalloonDriver() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("test",
+ ResourceAlertRule.ResourceType.VirtualMachine, VM_ID, 1L, 1L,
+ "MEMORY_UTILIZATION", AlertCondition.GT, 50.0, AlertSeverity.MEDIUM, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getMemoryKBs()).thenReturn(8192.0);
+ when(stats.getIntFreeMemoryKBs()).thenReturn(-1.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testVmMemoryUtilizationCalculation() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("test",
+ ResourceAlertRule.ResourceType.VirtualMachine, VM_ID, 1L, 1L,
+ "MEMORY_UTILIZATION", AlertCondition.GT, 70.0, AlertSeverity.HIGH, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getMemoryKBs()).thenReturn(8192.0);
+ when(stats.getIntFreeMemoryKBs()).thenReturn(2048.0); // 75% used
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(75.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testStorageUtilizationCalculation() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("test",
+ ResourceAlertRule.ResourceType.StoragePool, POOL_ID, 1L, 1L,
+ "STORAGE_UTILIZATION", AlertCondition.GT, 65.0, AlertSeverity.HIGH, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ StorageStats poolStats = mock(StorageStats.class);
+ when(poolStats.getCapacityBytes()).thenReturn(10000L);
+ when(poolStats.getByteUsed()).thenReturn(7000L); // 70%
+ when(statsCollector.getStoragePoolStats(POOL_ID)).thenReturn(poolStats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(POOL_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(70.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testGenericVmRuleFansOutToAllRunningVms() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ stubOwner(Account.Type.NORMAL);
+ when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running))
+ .thenReturn(Arrays.asList(101L, 102L));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(101L, false)).thenReturn(stats);
+ when(statsCollector.getVmStats(102L, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), anyLong())).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao, times(2)).persist(any());
+ }
+
+ @Test
+ public void testGenericVmRuleOnlyListsRunningVms() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ stubOwner(Account.Type.NORMAL);
+
+ manager.evaluateRules();
+
+ verify(userVmDao).listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running);
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testHostCpuRuleUsesHostStats() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("test",
+ ResourceAlertRule.ResourceType.Host, HOST_ID, 1L, 1L,
+ "CPU_UTILIZATION", AlertCondition.GT, 85.0, AlertSeverity.CRITICAL, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ HostStats hostStats = mock(HostStats.class);
+ when(hostStats.getCpuUtilization()).thenReturn(90.0);
+ when(statsCollector.getHostStats(HOST_ID)).thenReturn(hostStats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(HOST_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(HOST_ID, (long) alertCaptor.getValue().getResourceId());
+ assertEquals(90.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testHostMemoryUtilizationCalculation() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("test",
+ ResourceAlertRule.ResourceType.Host, HOST_ID, 1L, 1L,
+ "MEMORY_UTILIZATION", AlertCondition.GT, 80.0, AlertSeverity.HIGH, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ HostStats hostStats = mock(HostStats.class);
+ when(hostStats.getTotalMemoryKBs()).thenReturn(16384.0);
+ when(hostStats.getFreeMemoryKBs()).thenReturn(1638.4); // ~90% used
+ when(statsCollector.getHostStats(HOST_ID)).thenReturn(hostStats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(HOST_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(90.0, alertCaptor.getValue().getMetricValue(), 0.01);
+ }
+
+ @Test
+ public void testEventBusPublishedOnFiring() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ UserVmVO vm = mock(UserVmVO.class);
+ when(vm.getDataCenterId()).thenReturn(1L);
+ when(userVmDao.findById(VM_ID)).thenReturn(vm);
+
+ manager.evaluateRules();
+
+ verify(manager).publishAlertEvent(eq(1L), anyString(), anyString());
+ }
+
+ @Test
+ public void testEventBusNotPublishedWhenNoFiring() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(75.0); // below threshold
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(manager, never()).publishAlertEvent(anyLong(), anyString(), anyString());
+ }
+
+ @Test
+ public void testEmailSentWhenRuleHasEmailEnabled() throws Exception {
+ injectMailSender("admin@example.com");
+
+ ResourceAlertRuleVO rule = vmCpuRuleWithEmail(VM_ID, true);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(mailSender).sendMail(mailCaptor.capture());
+ SMTPMailProperties mail = mailCaptor.getValue();
+ assertTrue(mail.getSubject().contains("CPU_UTILIZATION"));
+ assertTrue(mail.getSubject().contains("HIGH"));
+ assertTrue(mail.getContent().toString().contains("85."));
+ }
+
+ @Test
+ public void testEmailSkippedWhenRuleHasEmailDisabled() throws Exception {
+ injectMailSender("admin@example.com");
+
+ ResourceAlertRuleVO rule = vmCpuRuleWithEmail(VM_ID, false);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(mailSender, never()).sendMail(any());
+ }
+
+ @Test
+ public void testEmailSkippedWhenNoRecipientsConfigured() throws Exception {
+ // mailSender injected but no recipients → should not attempt to send
+ injectMailSender(/* no recipients */);
+
+ ResourceAlertRuleVO rule = vmCpuRuleWithEmail(VM_ID, true);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(mailSender, never()).sendMail(any());
+ }
+
+ @Test
+ public void testSubjectContainsKeyAlertFields() throws Exception {
+ injectMailSender("admin@example.com");
+
+ ResourceAlertRuleVO rule = vmCpuRuleWithEmail(VM_ID, true);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(mailSender).sendMail(mailCaptor.capture());
+ String subject = mailCaptor.getValue().getSubject();
+ assertTrue(subject.contains("HIGH"));
+ assertTrue(subject.contains("CPU_UTILIZATION"));
+ assertTrue(subject.contains("GT"));
+ assertTrue(subject.contains("VirtualMachine"));
+ }
+
+ @Test
+ public void testGetDataCenterIdUsesVmDao() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ UserVmVO vm = mock(UserVmVO.class);
+ when(vm.getDataCenterId()).thenReturn(42L);
+ when(userVmDao.findById(VM_ID)).thenReturn(vm);
+
+ manager.evaluateRules();
+
+ verify(manager).publishAlertEvent(eq(42L), anyString(), anyString());
+ }
+
+ @Test
+ public void testGenericRuleSkipsOptedOutVm() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ stubOwner(Account.Type.NORMAL);
+ when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running))
+ .thenReturn(Collections.singletonList(VM_ID));
+
+ ResourceTag optOutTag = mock(ResourceTag.class);
+ when(optOutTag.getValue()).thenReturn("true");
+ when(resourceTagDao.findByKey(VM_ID, ResourceTag.ResourceObjectType.UserVm, "resource.alert.opt.out"))
+ .thenReturn(optOutTag);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testGenericRuleDoesNotSkipVmWithOptOutTagValueFalse() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ stubOwner(Account.Type.NORMAL);
+ when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running))
+ .thenReturn(Collections.singletonList(VM_ID));
+
+ ResourceTag tag = mock(ResourceTag.class);
+ when(tag.getValue()).thenReturn("false");
+ when(resourceTagDao.findByKey(VM_ID, ResourceTag.ResourceObjectType.UserVm, "resource.alert.opt.out"))
+ .thenReturn(tag);
+ when(ruleDao.existsSpecificRule(ResourceAlertRule.ResourceType.VirtualMachine, "CPU_UTILIZATION", VM_ID))
+ .thenReturn(false);
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(any());
+ }
+
+ @Test
+ public void testGenericRuleSkipsVmWithSpecificRuleForSameMetric() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ stubOwner(Account.Type.NORMAL);
+ when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running))
+ .thenReturn(Collections.singletonList(VM_ID));
+
+ when(resourceTagDao.findByKey(VM_ID, ResourceTag.ResourceObjectType.UserVm, "resource.alert.opt.out"))
+ .thenReturn(null);
+ when(ruleDao.existsSpecificRule(ResourceAlertRule.ResourceType.VirtualMachine, "CPU_UTILIZATION", VM_ID))
+ .thenReturn(true);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ @Test
+ public void testSpecificRuleIgnoresOptOutAndPrecedenceChecks() {
+ // specific rule (non-null resourceId) must not check opt-out or precedence
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(any());
+ verify(resourceTagDao, never()).findByKey(anyLong(), any(), anyString());
+ verify(ruleDao, never()).existsSpecificRule(any(), anyString(), anyLong());
+ }
+
+ @Test
+ public void testGetDataCenterIdFallsBackToZeroWhenVmNotFound() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running))
+ .thenReturn(Collections.singletonList(VM_ID));
+
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null);
+ when(userVmDao.findById(VM_ID)).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(manager).publishAlertEvent(eq(0L), anyString(), anyString());
+ }
+
+ private void stubFiringVmCpuRule(ResourceAlertRuleVO rule) {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ VmStats stats = mock(VmStats.class);
+ when(stats.getCPUUtilization()).thenReturn(85.0);
+ when(statsCollector.getVmStats(VM_ID, false)).thenReturn(stats);
+ }
+
+ @Test
+ public void testFiredAlertIsDeliveredToMappedWebhooks() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ stubFiringVmCpuRule(rule);
+ when(ruleWebhookDao.listWebhookIdsByRule(rule.getId())).thenReturn(Arrays.asList(11L, 12L));
+ doReturn(webhookHelper).when(manager).getWebhookHelper();
+ UserVmVO vm = mock(UserVmVO.class);
+ when(vm.getUuid()).thenReturn("vm-uuid");
+ when(userVmDao.findByIdIncludingRemoved(VM_ID)).thenReturn(vm);
+
+ manager.evaluateRules();
+
+ ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(String.class);
+ verify(webhookHelper).deliverToWebhooks(eq(Arrays.asList(11L, 12L)), eq(1L),
+ eq(ResourceAlertManagerImpl.ALERT_EVENT_TYPE), payloadCaptor.capture());
+ JsonObject payload = JsonParser.parseString(payloadCaptor.getValue()).getAsJsonObject();
+ assertEquals(rule.getUuid(), payload.get("ruleid").getAsString());
+ assertEquals("VirtualMachine", payload.get("resourcetype").getAsString());
+ assertEquals("vm-uuid", payload.get("resourceid").getAsString());
+ assertEquals("CPU_UTILIZATION", payload.get("metric").getAsString());
+ assertEquals(85.0, payload.get("value").getAsDouble(), 0.001);
+ assertEquals("HIGH", payload.get("severity").getAsString());
+ }
+
+ @Test
+ public void testFiredAlertWithoutMappedWebhooksSkipsDelivery() {
+ stubFiringVmCpuRule(vmCpuRule(VM_ID));
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(any());
+ verify(manager, never()).getWebhookHelper();
+ }
+
+ private ResourceAlertRuleVO hostRule(String metric, double threshold) {
+ return new ResourceAlertRuleVO("host", ResourceAlertRule.ResourceType.Host,
+ HOST_ID, 1L, 1L, metric, AlertCondition.GT, threshold,
+ AlertSeverity.HIGH, null, false, 600);
+ }
+
+ @Test
+ public void testHostLoadAverageRuleFires() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(hostRule("LOAD_AVERAGE", 4.0)));
+ HostStats stats = mock(HostStats.class);
+ when(stats.getLoadAverage()).thenReturn(6.5);
+ when(statsCollector.getHostStats(HOST_ID)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(6.5, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testHostNetworkReadRuleUsesHostStats() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(hostRule("NETWORK_READ_KBPS", 1000.0)));
+ HostStats stats = mock(HostStats.class);
+ when(stats.getNetworkReadKBs()).thenReturn(2500.0);
+ when(statsCollector.getHostStats(HOST_ID)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(2500.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ verify(statsCollector, never()).getVmStats(anyLong(), any(Boolean.class));
+ }
+
+ @Test
+ public void testHostNetworkWriteRuleDoesNotFireBelowThreshold() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(hostRule("NETWORK_WRITE_KBPS", 1000.0)));
+ HostStats stats = mock(HostStats.class);
+ when(stats.getNetworkWriteKBs()).thenReturn(10.0);
+ when(statsCollector.getHostStats(HOST_ID)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ private static final long VOLUME_ID = 401L;
+
+ private ResourceAlertRuleVO volumeSizeRule(double thresholdGb) {
+ return new ResourceAlertRuleVO("vol", ResourceAlertRule.ResourceType.Volume,
+ VOLUME_ID, 1L, 1L, "VOLUME_SIZE_GB", AlertCondition.GT, thresholdGb,
+ AlertSeverity.MEDIUM, null, false, 600);
+ }
+
+ @Test
+ public void testVolumeSizeRuleUsesPhysicalSizeByPath() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(volumeSizeRule(10.0)));
+ VolumeVO vol = mock(VolumeVO.class);
+ when(vol.getFormat()).thenReturn(Storage.ImageFormat.QCOW2);
+ when(vol.getPath()).thenReturn("vol-path");
+ when(volumeDao.findById(VOLUME_ID)).thenReturn(vol);
+ VolumeStats stats = mock(VolumeStats.class);
+ when(stats.getPhysicalSize()).thenReturn(20L * 1024 * 1024 * 1024);
+ when(statsCollector.getVolumeStats("vol-path")).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(20.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testVolumeSizeRuleUsesChainInfoForOva() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(volumeSizeRule(10.0)));
+ VolumeVO vol = mock(VolumeVO.class);
+ when(vol.getFormat()).thenReturn(Storage.ImageFormat.OVA);
+ when(vol.getChainInfo()).thenReturn("chain-info");
+ when(volumeDao.findById(VOLUME_ID)).thenReturn(vol);
+
+ manager.evaluateRules();
+
+ verify(statsCollector).getVolumeStats("chain-info");
+ verify(alertDao, never()).persist(any());
+ }
+
+ private ResourceAlertRuleVO poolIopsRule(double threshold) {
+ return new ResourceAlertRuleVO("pool-iops", ResourceAlertRule.ResourceType.StoragePool,
+ POOL_ID, 1L, 1L, "STORAGE_USED_IOPS", AlertCondition.GT, threshold,
+ AlertSeverity.HIGH, null, false, 600);
+ }
+
+ @Test
+ public void testStoragePoolIopsRuleFires() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(poolIopsRule(1000.0)));
+ StorageStats stats = mock(StorageStats.class);
+ when(stats.getUsedIops()).thenReturn(5000L);
+ when(statsCollector.getStoragePoolStats(POOL_ID)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao).persist(alertCaptor.capture());
+ assertEquals(5000.0, alertCaptor.getValue().getMetricValue(), 0.001);
+ }
+
+ @Test
+ public void testStoragePoolIopsRuleSkippedWhenDriverDoesNotReportIops() {
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(poolIopsRule(1000.0)));
+ StorageStats stats = mock(StorageStats.class);
+ when(stats.getUsedIops()).thenReturn(null);
+ when(statsCollector.getStoragePoolStats(POOL_ID)).thenReturn(stats);
+
+ manager.evaluateRules();
+
+ verify(alertDao, never()).persist(any());
+ }
+
+ private AccountVO stubOwner(Account.Type type) {
+ AccountVO owner = mock(AccountVO.class);
+ when(owner.getType()).thenReturn(type);
+ lenient().when(owner.getId()).thenReturn(1L);
+ lenient().when(owner.getDomainId()).thenReturn(5L);
+ when(accountDao.findById(1L)).thenReturn(owner);
+ return owner;
+ }
+
+ @Test
+ public void testGenericRuleScopeForRootAdminIsWholeCloud() {
+ stubOwner(Account.Type.ADMIN);
+
+ Pair> scope = manager.getGenericRuleScope(vmCpuRule(null));
+
+ assertNull(scope.first());
+ assertNull(scope.second());
+ }
+
+ @Test
+ public void testGenericRuleScopeForDomainAdminIsDomainTree() {
+ stubOwner(Account.Type.DOMAIN_ADMIN);
+ when(domainDao.getDomainAndChildrenIds(5L)).thenReturn(Arrays.asList(5L, 6L));
+
+ Pair> scope = manager.getGenericRuleScope(vmCpuRule(null));
+
+ assertNull(scope.first());
+ assertEquals(Arrays.asList(5L, 6L), scope.second());
+ }
+
+ @Test
+ public void testGenericRuleScopeForUserIsOwnAccount() {
+ stubOwner(Account.Type.NORMAL);
+
+ Pair> scope = manager.getGenericRuleScope(vmCpuRule(null));
+
+ assertEquals(Long.valueOf(1L), scope.first());
+ assertNull(scope.second());
+ }
+
+ @Test
+ public void testGenericVolumeRuleForRootAdminListsReadyVolumesCloudWide() {
+ ResourceAlertRuleVO rule = new ResourceAlertRuleVO("vol", ResourceAlertRule.ResourceType.Volume,
+ null, 1L, 1L, "VOLUME_SIZE_GB", AlertCondition.GT, 10.0, AlertSeverity.LOW, null, false, 600);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ stubOwner(Account.Type.ADMIN);
+
+ manager.evaluateRules();
+
+ verify(volumeDao).listIdsByAccountOrDomainsAndState(null, null, Volume.State.Ready);
+ }
+
+ @Test
+ public void testRuleRemovedWhenOwnerMissing() {
+ ResourceAlertRuleVO rule = vmCpuRule(null);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ when(accountDao.findById(1L)).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(ruleDao).remove(rule.getId());
+ verify(userVmDao, never()).listIdsByAccountOrDomainsAndState(any(), any(), any());
+ }
+
+ @Test
+ public void testSpecificRuleRemovedWhenResourceExpunged() {
+ ResourceAlertRuleVO rule = vmCpuRule(VM_ID);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+ when(userVmDao.findById(VM_ID)).thenReturn(null);
+
+ manager.evaluateRules();
+
+ verify(ruleDao).remove(rule.getId());
+ verify(statsCollector, never()).getVmStats(anyLong(), any(Boolean.class));
+ }
+
+ @Test
+ public void testSpecificHostRuleKeptWhileHostExists() {
+ ResourceAlertRuleVO rule = hostRule("CPU_UTILIZATION", 90.0);
+ when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule));
+
+ manager.evaluateRules();
+
+ verify(ruleDao, never()).remove(anyLong());
+ }
+
+ @Test
+ public void testEvaluatesOnLongestRunningManagementServer() {
+ ManagementServerHostVO msHost = mock(ManagementServerHostVO.class);
+ when(msHost.getMsid()).thenReturn(ManagementServerNode.getManagementServerId());
+ when(managementServerHostDao.findOneByLongestRuntime()).thenReturn(msHost);
+
+ assertTrue(manager.isEvaluatingServer());
+ }
+
+ @Test
+ public void testDoesNotEvaluateOnOtherManagementServers() {
+ ManagementServerHostVO msHost = mock(ManagementServerHostVO.class);
+ when(msHost.getMsid()).thenReturn(ManagementServerNode.getManagementServerId() + 1);
+ when(managementServerHostDao.findOneByLongestRuntime()).thenReturn(msHost);
+
+ assertFalse(manager.isEvaluatingServer());
+ }
+
+ @Test
+ public void testDoesNotEvaluateWhenNoManagementServerFound() {
+ assertFalse(manager.isEvaluatingServer());
+ }
+
+ @Test
+ public void testRemoveExpiredAlertsUsesRetentionDays() {
+ long before = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30);
+
+ manager.removeExpiredAlerts();
+
+ ArgumentCaptor cutoff = ArgumentCaptor.forClass(Date.class);
+ verify(alertDao).removeOlderThan(cutoff.capture());
+ long diff = Math.abs(cutoff.getValue().getTime() - before);
+ assertTrue("cutoff should be about 30 days ago", diff < 60_000L);
+ }
+}
diff --git a/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertMetricTest.java b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertMetricTest.java
new file mode 100644
index 000000000000..cc004908c2c3
--- /dev/null
+++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertMetricTest.java
@@ -0,0 +1,106 @@
+// 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.resourcealert;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class ResourceAlertMetricTest {
+
+ @Test
+ public void testCpuAppliesToVmAndHost() {
+ assertTrue(ResourceAlertMetric.CPU_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertTrue(ResourceAlertMetric.CPU_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.CPU_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ assertFalse(ResourceAlertMetric.CPU_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ }
+
+ @Test
+ public void testMemoryAppliesToVmAndHost() {
+ assertTrue(ResourceAlertMetric.MEMORY_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertTrue(ResourceAlertMetric.MEMORY_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.MEMORY_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ assertFalse(ResourceAlertMetric.MEMORY_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ }
+
+ @Test
+ public void testDiskMetricsApplyToVmAndVolume() {
+ for (ResourceAlertMetric m : new ResourceAlertMetric[]{
+ ResourceAlertMetric.DISK_READ_IOPS, ResourceAlertMetric.DISK_WRITE_IOPS,
+ ResourceAlertMetric.DISK_READ_KBPS, ResourceAlertMetric.DISK_WRITE_KBPS}) {
+ assertTrue(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertTrue(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ assertFalse(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ }
+ }
+
+ @Test
+ public void testNetworkMetricsApplyToVmAndHost() {
+ for (ResourceAlertMetric m : new ResourceAlertMetric[]{
+ ResourceAlertMetric.NETWORK_READ_KBPS, ResourceAlertMetric.NETWORK_WRITE_KBPS}) {
+ assertTrue(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertTrue(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ assertFalse(m.name(), m.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ }
+ }
+
+ @Test
+ public void testStorageUtilizationAppliesToStoragePoolOnly() {
+ assertTrue(ResourceAlertMetric.STORAGE_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ assertFalse(ResourceAlertMetric.STORAGE_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertFalse(ResourceAlertMetric.STORAGE_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.STORAGE_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ }
+
+ @Test
+ public void testLoadAverageAppliesToHostOnly() {
+ assertTrue(ResourceAlertMetric.LOAD_AVERAGE.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.LOAD_AVERAGE.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertFalse(ResourceAlertMetric.LOAD_AVERAGE.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ assertFalse(ResourceAlertMetric.LOAD_AVERAGE.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ }
+
+ @Test
+ public void testVolumeSizeAppliesToVolumeOnly() {
+ assertTrue(ResourceAlertMetric.VOLUME_SIZE_GB.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ assertFalse(ResourceAlertMetric.VOLUME_SIZE_GB.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ assertFalse(ResourceAlertMetric.VOLUME_SIZE_GB.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.VOLUME_SIZE_GB.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ }
+
+ @Test
+ public void testStorageUsedIopsAppliesToStoragePoolOnly() {
+ assertTrue(ResourceAlertMetric.STORAGE_USED_IOPS.appliesTo(ResourceAlertRule.ResourceType.StoragePool));
+ assertFalse(ResourceAlertMetric.STORAGE_USED_IOPS.appliesTo(ResourceAlertRule.ResourceType.Volume));
+ assertFalse(ResourceAlertMetric.STORAGE_USED_IOPS.appliesTo(ResourceAlertRule.ResourceType.Host));
+ assertFalse(ResourceAlertMetric.STORAGE_USED_IOPS.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine));
+ }
+
+ @Test
+ public void testPercentageMetrics() {
+ assertTrue(ResourceAlertMetric.CPU_UTILIZATION.isPercentage());
+ assertTrue(ResourceAlertMetric.MEMORY_UTILIZATION.isPercentage());
+ assertTrue(ResourceAlertMetric.STORAGE_UTILIZATION.isPercentage());
+ assertFalse(ResourceAlertMetric.DISK_READ_IOPS.isPercentage());
+ assertFalse(ResourceAlertMetric.VOLUME_SIZE_GB.isPercentage());
+ }
+}
diff --git a/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImplTest.java b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImplTest.java
new file mode 100644
index 000000000000..c684f14e9780
--- /dev/null
+++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImplTest.java
@@ -0,0 +1,491 @@
+// 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.resourcealert;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.cloudstack.acl.ControlledEntity;
+import org.apache.cloudstack.api.InternalIdentity;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.resourcealert.api.command.user.CreateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.DeleteResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.ListResourceAlertsCmd;
+import org.apache.cloudstack.resourcealert.api.command.user.UpdateResourceAlertRuleCmd;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleJoinDao;
+import org.apache.cloudstack.resourcealert.dao.ResourceAlertRuleWebhookDao;
+import org.apache.cloudstack.api.response.ListResponse;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertResponse;
+import org.apache.cloudstack.resourcealert.api.response.ResourceAlertRuleResponse;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleJoinVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertRuleVO;
+import org.apache.cloudstack.resourcealert.vo.ResourceAlertVO;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.webhook.WebhookHelper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.exception.PermissionDeniedException;
+import com.cloud.host.dao.HostDao;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.user.Account;
+import com.cloud.user.AccountManager;
+import com.cloud.utils.Pair;
+import com.cloud.vm.UserVmVO;
+import com.cloud.vm.dao.UserVmDao;
+
+@RunWith(MockitoJUnitRunner.Silent.class)
+public class ResourceAlertServiceImplTest {
+
+ @Spy
+ @InjectMocks
+ ResourceAlertServiceImpl service;
+
+ @Mock AccountManager accountManager;
+ @Mock ResourceAlertRuleDao ruleDao;
+ @Mock ResourceAlertRuleJoinDao ruleJoinDao;
+ @Mock ResourceAlertDao alertDao;
+ @Mock ResourceAlertRuleWebhookDao ruleWebhookDao;
+ @Mock WebhookHelper webhookHelper;
+ @Mock UserVmDao userVmDao;
+ @Mock VolumeDao volumeDao;
+ @Mock HostDao hostDao;
+ @Mock PrimaryDataStoreDao storagePoolDao;
+
+ private MockedStatic callContextMocked;
+ private Account caller;
+ private Account owner;
+
+ @Before
+ public void setUp() {
+ caller = mock(Account.class);
+ when(caller.getId()).thenReturn(2L);
+ CallContext callContext = mock(CallContext.class);
+ when(callContext.getCallingAccount()).thenReturn(caller);
+ callContextMocked = Mockito.mockStatic(CallContext.class);
+ callContextMocked.when(CallContext::current).thenReturn(callContext);
+
+ owner = mock(Account.class);
+ when(owner.getId()).thenReturn(42L);
+ when(accountManager.finalizeOwner(eq(caller), any(), any(), any())).thenReturn(owner);
+ }
+
+ @After
+ public void tearDown() {
+ callContextMocked.close();
+ }
+
+ private CreateResourceAlertRuleCmd validVmCreateCmd() {
+ CreateResourceAlertRuleCmd cmd = mock(CreateResourceAlertRuleCmd.class);
+ when(cmd.getName()).thenReturn("cpu-high");
+ when(cmd.getResourceType()).thenReturn("VirtualMachine");
+ when(cmd.getCondition()).thenReturn("GT");
+ when(cmd.getSeverity()).thenReturn("HIGH");
+ when(cmd.getMetric()).thenReturn("CPU_UTILIZATION");
+ when(cmd.getThreshold()).thenReturn(80.0);
+ when(cmd.getResetInterval()).thenReturn(null);
+ when(cmd.getEmail()).thenReturn(null);
+ return cmd;
+ }
+
+ private ResourceAlertRuleVO persistedRuleCapture() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceAlertRuleVO.class);
+ verify(ruleDao).persist(captor.capture());
+ return captor.getValue();
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnInvalidCondition() {
+ CreateResourceAlertRuleCmd cmd = mock(CreateResourceAlertRuleCmd.class);
+ when(cmd.getResourceType()).thenReturn("VirtualMachine");
+ when(cmd.getCondition()).thenReturn("GREATER_THAN");
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnInvalidSeverity() {
+ CreateResourceAlertRuleCmd cmd = mock(CreateResourceAlertRuleCmd.class);
+ when(cmd.getResourceType()).thenReturn("VirtualMachine");
+ when(cmd.getCondition()).thenReturn("GT");
+ when(cmd.getSeverity()).thenReturn("URGENT");
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnInvalidResourceType() {
+ CreateResourceAlertRuleCmd cmd = mock(CreateResourceAlertRuleCmd.class);
+ when(cmd.getResourceType()).thenReturn("Database");
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsWhenMetricDoesNotApplyToResourceType() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getMetric()).thenReturn("STORAGE_UTILIZATION");
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsWhenAccountAtRuleLimit() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ // default limit is 20
+ when(ruleDao.countActiveByAccountId(42L)).thenReturn(20);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testCreateUsesDefaultResetIntervalWhenNotSet() {
+ service.createResourceAlertRule(validVmCreateCmd());
+
+ assertEquals(600, persistedRuleCapture().getResetInterval());
+ }
+
+ @Test
+ public void testCreateAssignsRuleToFinalizedOwner() {
+ service.createResourceAlertRule(validVmCreateCmd());
+
+ assertEquals(42L, persistedRuleCapture().getAccountId());
+ }
+
+ @Test
+ public void testCreateResolvesResourceUuidAndChecksOwnerAccess() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceId()).thenReturn("vm-uuid");
+ UserVmVO vm = mock(UserVmVO.class);
+ when(vm.getId()).thenReturn(7L);
+ when(userVmDao.findByUuid("vm-uuid")).thenReturn(vm);
+
+ service.createResourceAlertRule(cmd);
+
+ verify(accountManager).checkAccess(owner, null, false, (ControlledEntity) vm);
+ assertEquals(Long.valueOf(7L), persistedRuleCapture().getResourceId());
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testCreateFailsWhenOwnerCannotAccessResource() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceId()).thenReturn("vm-uuid");
+ UserVmVO vm = mock(UserVmVO.class);
+ when(userVmDao.findByUuid("vm-uuid")).thenReturn(vm);
+ doThrow(new PermissionDeniedException("denied"))
+ .when(accountManager).checkAccess(owner, null, false, (ControlledEntity) vm);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnUnknownResourceUuid() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceId()).thenReturn("no-such-vm");
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testCreateHostRuleFailsForNonRootAdmin() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceType()).thenReturn("Host");
+ when(accountManager.isRootAdmin(2L)).thenReturn(false);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testCreateHostRuleAllowedForRootAdmin() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceType()).thenReturn("Host");
+ when(accountManager.isRootAdmin(2L)).thenReturn(true);
+
+ service.createResourceAlertRule(cmd);
+
+ verify(ruleDao).persist(any(ResourceAlertRuleVO.class));
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testCreateWithEmailFailsForNonRootAdmin() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getEmail()).thenReturn(true);
+ when(accountManager.isRootAdmin(2L)).thenReturn(false);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testUpdateFailsWhenRuleNotFound() {
+ UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(999L);
+
+ service.updateResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testUpdateFailsWhenRuleAlreadyDeleted() {
+ UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(1L);
+ ResourceAlertRuleVO deletedRule = mock(ResourceAlertRuleVO.class);
+ when(deletedRule.getRemoved()).thenReturn(new java.util.Date());
+ when(ruleDao.findById(1L)).thenReturn(deletedRule);
+
+ service.updateResourceAlertRule(cmd);
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testUpdateFailsWhenCallerCannotAccessRule() {
+ UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(1L);
+ ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class);
+ when(ruleDao.findById(1L)).thenReturn(rule);
+ doThrow(new PermissionDeniedException("denied")).when(accountManager).checkAccess(caller, null, true, rule);
+
+ service.updateResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testDeleteFailsWhenRuleNotFound() {
+ DeleteResourceAlertRuleCmd cmd = mock(DeleteResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(999L);
+
+ service.deleteResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testDeleteDoesNotRemoveWhenCallerCannotAccessRule() {
+ DeleteResourceAlertRuleCmd cmd = mock(DeleteResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(1L);
+ ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class);
+ when(ruleDao.findById(1L)).thenReturn(rule);
+ doThrow(new PermissionDeniedException("denied")).when(accountManager).checkAccess(caller, null, true, rule);
+
+ try {
+ service.deleteResourceAlertRule(cmd);
+ } catch (PermissionDeniedException expected) {
+ }
+ verify(ruleDao, never()).remove(1L);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testListAlertsFailsWithUnknownRuleUuid() {
+ ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class);
+ when(cmd.getAlertRuleId()).thenReturn("no-such-uuid");
+
+ service.listResourceAlerts(cmd);
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testListAlertsFailsWhenCallerCannotAccessRule() {
+ ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class);
+ when(cmd.getAlertRuleId()).thenReturn("rule-uuid");
+ ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class);
+ when(ruleDao.findByUuid("rule-uuid")).thenReturn(rule);
+ doThrow(new PermissionDeniedException("denied")).when(accountManager).checkAccess(caller, null, true, rule);
+
+ service.listResourceAlerts(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testListAlertsFailsWhenResourceIdWithoutType() {
+ ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class);
+ when(cmd.getResourceId()).thenReturn("vm-uuid");
+
+ service.listResourceAlerts(cmd);
+ }
+
+ private ControlledEntity mockWebhook(String uuid, long id) {
+ ControlledEntity webhook = mock(ControlledEntity.class, Mockito.withSettings().extraInterfaces(InternalIdentity.class));
+ when(((InternalIdentity) webhook).getId()).thenReturn(id);
+ when(webhookHelper.findWebhookByUuid(uuid)).thenReturn(webhook);
+ return webhook;
+ }
+
+ @Test
+ public void testCreateMapsWebhooksAfterCheckingOwnerAccess() {
+ doReturn(webhookHelper).when(service).getWebhookHelper();
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getWebhookIds()).thenReturn(List.of("wh-1", "wh-1"));
+ ControlledEntity webhook = mockWebhook("wh-1", 11L);
+
+ service.createResourceAlertRule(cmd);
+
+ verify(accountManager, Mockito.times(2)).checkAccess(owner, null, false, webhook);
+ verify(ruleWebhookDao).replaceWebhooksForRule(Mockito.anyLong(), eq(List.of(11L)));
+ }
+
+ @Test(expected = PermissionDeniedException.class)
+ public void testCreateFailsWhenOwnerCannotAccessWebhook() {
+ doReturn(webhookHelper).when(service).getWebhookHelper();
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getWebhookIds()).thenReturn(List.of("wh-1"));
+ ControlledEntity webhook = mockWebhook("wh-1", 11L);
+ doThrow(new PermissionDeniedException("denied")).when(accountManager).checkAccess(owner, null, false, webhook);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnUnknownWebhook() {
+ doReturn(webhookHelper).when(service).getWebhookHelper();
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getWebhookIds()).thenReturn(List.of("no-such-webhook"));
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateWithWebhooksFailsWhenWebhookPluginMissing() {
+ doReturn(null).when(service).getWebhookHelper();
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getWebhookIds()).thenReturn(List.of("wh-1"));
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testCreateWithoutWebhooksDoesNotTouchMapping() {
+ service.createResourceAlertRule(validVmCreateCmd());
+
+ verify(ruleWebhookDao, never()).replaceWebhooksForRule(Mockito.anyLong(), any());
+ }
+
+ @Test
+ public void testUpdateCleanupWebhooksClearsMapping() {
+ UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(1L);
+ when(cmd.isCleanupWebhooks()).thenReturn(true);
+ when(cmd.getThreshold()).thenReturn(null);
+ when(cmd.getResetInterval()).thenReturn(null);
+ ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class);
+ when(rule.getId()).thenReturn(1L);
+ when(ruleDao.findById(1L)).thenReturn(rule);
+
+ service.updateResourceAlertRule(cmd);
+
+ verify(ruleWebhookDao).replaceWebhooksForRule(1L, new ArrayList<>());
+ }
+
+ @Test
+ public void testListAlertsPassesPagingAndReturnsTotalCount() {
+ ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class);
+ when(cmd.getDomainId()).thenReturn(null);
+ when(cmd.getStartIndex()).thenReturn(20L);
+ when(cmd.getPageSizeVal()).thenReturn(10L);
+ ResourceAlertVO alert = mock(ResourceAlertVO.class);
+ when(alertDao.searchAndCountByFilters(null, null, null, null, null, 20L, 10L))
+ .thenReturn(new Pair<>(List.of(alert), 57));
+
+ ListResponse response = service.listResourceAlerts(cmd);
+
+ assertEquals(Integer.valueOf(57), response.getCount());
+ assertEquals(1, response.getResponses().size());
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnPercentageThresholdAbove100() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getThreshold()).thenReturn(150.0);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnNegativeThreshold() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getMetric()).thenReturn("NETWORK_READ_KBPS");
+ when(cmd.getThreshold()).thenReturn(-1.0);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testCreateAllowsNonPercentageThresholdAbove100() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getMetric()).thenReturn("NETWORK_READ_KBPS");
+ when(cmd.getThreshold()).thenReturn(5000.0);
+
+ service.createResourceAlertRule(cmd);
+
+ verify(ruleDao).persist(any(ResourceAlertRuleVO.class));
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testCreateFailsOnNegativeResetInterval() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResetInterval()).thenReturn(-5);
+
+ service.createResourceAlertRule(cmd);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testUpdateFailsOnPercentageThresholdAbove100() {
+ UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class);
+ when(cmd.getId()).thenReturn(1L);
+ when(cmd.getThreshold()).thenReturn(101.0);
+ ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class);
+ when(rule.getMetric()).thenReturn("CPU_UTILIZATION");
+ when(ruleDao.findById(1L)).thenReturn(rule);
+
+ service.updateResourceAlertRule(cmd);
+ }
+
+ @Test
+ public void testRuleResponseIncludesResourceName() {
+ CreateResourceAlertRuleCmd cmd = validVmCreateCmd();
+ when(cmd.getResourceId()).thenReturn("vm-uuid");
+ UserVmVO vm = mock(UserVmVO.class);
+ when(vm.getId()).thenReturn(7L);
+ when(vm.getUuid()).thenReturn("vm-uuid");
+ when(vm.getDisplayName()).thenReturn("web-01");
+ when(userVmDao.findByUuid("vm-uuid")).thenReturn(vm);
+ when(userVmDao.findByIdIncludingRemoved(7L)).thenReturn(vm);
+ ResourceAlertRuleJoinVO joined = mock(ResourceAlertRuleJoinVO.class);
+ when(joined.getResourceType()).thenReturn(ResourceAlertRule.ResourceType.VirtualMachine);
+ when(joined.getResourceId()).thenReturn(7L);
+ when(ruleJoinDao.findById(Mockito.anyLong())).thenReturn(joined);
+
+ ResourceAlertRuleResponse response = service.createResourceAlertRule(cmd);
+
+ assertEquals("vm-uuid", org.springframework.test.util.ReflectionTestUtils.getField(response, "resourceId"));
+ assertEquals("web-01", org.springframework.test.util.ReflectionTestUtils.getField(response, "resourceName"));
+ }
+}
diff --git a/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java b/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java
index 4f2305004a97..a328c5fe54c4 100644
--- a/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java
+++ b/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java
@@ -25,4 +25,10 @@ public interface WebhookHelper {
void deleteWebhooksForAccount(long accountId);
List extends ControlledEntity> listWebhooksByAccount(long accountId);
+
+ ControlledEntity findWebhookByUuid(String uuid);
+
+ String getWebhookUuid(long webhookId);
+
+ void deliverToWebhooks(List webhookIds, long accountId, String eventType, String payload);
}
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 99bf2cf7aef9..0496dccd8cc5 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -1372,6 +1372,7 @@
"label.info": "Info",
"label.info.upper": "INFO",
"label.infrastructure": "Infrastructure",
+"label.monitoring": "Monitoring",
"label.ingest.instance": "Ingest Instance",
"label.ingress": "Ingress",
"label.ingress.rule": "Ingress Rule",
@@ -2316,6 +2317,25 @@
"label.resourcegroup": "Resource group",
"label.linstor.apitoken": "Controller API token",
"label.linstor.ssl.insecure": "Allow self-signed certificate",
+"label.resource.alert.rules": "Resource Alerts",
+"label.resource.alerts": "Alerts",
+"label.create.resource.alert.rule": "New Resource Alert",
+"label.firedalerts": "Alert History",
+"label.resourcealerts": "Alerts",
+"label.metric": "Metric",
+"label.condition": "Condition",
+"label.severity": "Severity",
+"label.message": "Message",
+"label.resetinterval": "Cooldown (seconds)",
+"label.resource.alert.all.resources": "All resources",
+"label.resource.alert.owner.self": "Your own account",
+"label.alertrulename": "Alert rule",
+"label.webhookids": "Webhooks",
+"label.cleanupwebhooks": "Remove all webhooks",
+"label.alerttimestamp": "Alert Time",
+"label.metrictype": "Metric",
+"label.metricvalue": "Value",
+"message.confirm.delete.resource.alert.rule": "Are you sure you want to delete this alert?",
"label.routingmode": "Routing mode",
"label.routing.policy": "Routing policy",
"label.routing.policy.terms": "Routing policy terms",
diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue
index 9a7d874fef3e..a928fc492a6a 100644
--- a/ui/src/components/view/ListView.vue
+++ b/ui/src/components/view/ListView.vue
@@ -1222,7 +1222,7 @@ export default {
'/computeoffering', '/systemoffering', '/diskoffering', '/backupoffering', '/networkoffering', '/vpcoffering',
'/tungstenfabric', '/oauthsetting', '/guestos', '/guestoshypervisormapping', '/webhook', 'webhookdeliveries', 'webhookfilters', '/quotatariff', '/sharedfs',
'/ipv4subnets', '/managementserver', '/gpucard', '/gpudevices', '/vgpuprofile', '/extension', '/snapshotpolicy', '/backupschedule',
- '/kmskey', '/hsmprofile', '/dnsserver', '/dnszone'].join('|'))
+ '/kmskey', '/hsmprofile', '/dnsserver', '/dnszone', '/resourcealerts'].join('|'))
.test(this.$route.path)
},
enableGroupAction () {
@@ -1231,7 +1231,8 @@ export default {
'project', 'account', 'systemvm', 'router', 'computeoffering', 'systemoffering',
'diskoffering', 'backupoffering', 'networkoffering', 'vpcoffering', 'ilbvm', 'kubernetes', 'comment', 'buckets',
'webhook', 'webhookdeliveries', 'sharedfs', 'ipv4subnets', 'asnumbers', 'guestos', 'gpucard', 'gpudevices', 'vgpuprofile',
- 'quotatariff'].includes(this.$route.name)
+ 'quotatariff', 'resourcealerts'
+ ].includes(this.$route.name)
},
getDateAtTimeZone (date, timezone) {
return date ? moment(date).tz(timezone).format('YYYY-MM-DD HH:mm:ss') : null
diff --git a/ui/src/components/view/ResourceAlertFiredTab.vue b/ui/src/components/view/ResourceAlertFiredTab.vue
new file mode 100644
index 000000000000..646a0270c666
--- /dev/null
+++ b/ui/src/components/view/ResourceAlertFiredTab.vue
@@ -0,0 +1,97 @@
+// 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.
+
+
+
+
+
+
+ {{ $toLocaleDate(text) }}
+
+
+ {{ Number(text).toFixed(2) }}
+
+
+ {{ text }}
+
+
+
+
+
+
+
diff --git a/ui/src/components/view/ResourceAlertsTab.vue b/ui/src/components/view/ResourceAlertsTab.vue
new file mode 100644
index 000000000000..a6665f0798d8
--- /dev/null
+++ b/ui/src/components/view/ResourceAlertsTab.vue
@@ -0,0 +1,101 @@
+// 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.
+
+
+
+
+
+
+ {{ $toLocaleDate(text) }}
+
+
+ {{ Number(text).toFixed(2) }}
+
+
+ {{ text }}
+
+
+
+
+
+
+
diff --git a/ui/src/config/router.js b/ui/src/config/router.js
index b9c60bcd0c21..30b468764b69 100644
--- a/ui/src/config/router.js
+++ b/ui/src/config/router.js
@@ -43,6 +43,7 @@ import config from '@/config/section/config'
import extension from '@/config/section/extension'
import customaction from '@/config/section/extension/customaction'
import tools from '@/config/section/tools'
+import monitoring from '@/config/section/monitoring'
import quota from '@/config/section/plugin/quota'
import cloudian from '@/config/section/plugin/cloudian'
@@ -229,6 +230,7 @@ export function asyncRouterMap () {
generateRouterMap(account),
generateRouterMap(domain),
generateRouterMap(infra),
+ generateRouterMap(monitoring),
generateRouterMap(zone),
generateRouterMap(offering),
generateRouterMap(config),
diff --git a/ui/src/config/section/infra/hosts.js b/ui/src/config/section/infra/hosts.js
index 046f120dd37a..ce2e356dba3c 100644
--- a/ui/src/config/section/infra/hosts.js
+++ b/ui/src/config/section/infra/hosts.js
@@ -53,6 +53,11 @@ export default {
name: 'gpu',
resourceType: 'Host',
component: shallowRef(defineAsyncComponent(() => import('@/components/view/GPUTab.vue')))
+ }, {
+ name: 'resourcealerts',
+ resourceType: 'Host',
+ component: shallowRef(defineAsyncComponent(() => import('@/components/view/ResourceAlertsTab.vue'))),
+ show: () => { return 'listResourceAlerts' in store.getters.apis }
}, {
name: 'events',
resourceType: 'Host',
diff --git a/ui/src/config/section/infra/primaryStorages.js b/ui/src/config/section/infra/primaryStorages.js
index f127a0853b9e..bb7bd3fde703 100644
--- a/ui/src/config/section/infra/primaryStorages.js
+++ b/ui/src/config/section/infra/primaryStorages.js
@@ -66,6 +66,11 @@ export default {
name: 'browser',
resourceType: 'PrimaryStorage',
component: shallowRef(defineAsyncComponent(() => import('@/views/infra/StorageBrowser.vue')))
+ }, {
+ name: 'resourcealerts',
+ resourceType: 'StoragePool',
+ component: shallowRef(defineAsyncComponent(() => import('@/components/view/ResourceAlertsTab.vue'))),
+ show: () => { return 'listResourceAlerts' in store.getters.apis }
}, {
name: 'events',
resourceType: 'StoragePool',
diff --git a/ui/src/config/section/infra/resourceAlertRules.js b/ui/src/config/section/infra/resourceAlertRules.js
new file mode 100644
index 000000000000..d32c09548c39
--- /dev/null
+++ b/ui/src/config/section/infra/resourceAlertRules.js
@@ -0,0 +1,79 @@
+// 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.
+
+import { shallowRef, defineAsyncComponent } from 'vue'
+import store from '@/store'
+
+export default {
+ name: 'resourcealerts',
+ title: 'label.resource.alert.rules',
+ icon: 'BellOutlined',
+ permission: ['listResourceAlertRules'],
+ columns: () => {
+ const cols = ['name', 'resourcetype', 'resourcename', 'metric', 'condition', 'threshold', 'severity']
+ if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) cols.push('account')
+ return cols
+ },
+ details: ['name', 'id', 'resourcetype', 'resourcename', 'resourceid', 'metric', 'condition', 'threshold', 'severity', 'message', 'email', 'resetinterval', 'account', 'domain', 'created'],
+ searchFilters: ['name', 'resourcetype'],
+ tabs: [{
+ name: 'details',
+ component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue')))
+ }, {
+ name: 'firedalerts',
+ component: shallowRef(defineAsyncComponent(() => import('@/components/view/ResourceAlertFiredTab.vue'))),
+ show: () => { return 'listResourceAlerts' in store.getters.apis }
+ }],
+ actions: [
+ {
+ api: 'createResourceAlertRule',
+ icon: 'plus-outlined',
+ label: 'label.create.resource.alert.rule',
+ listView: true,
+ popup: true,
+ component: shallowRef(defineAsyncComponent(() => import('@/views/resourcealert/CreateResourceAlertRule.vue')))
+ },
+ {
+ api: 'updateResourceAlertRule',
+ icon: 'edit-outlined',
+ label: 'label.edit',
+ dataView: true,
+ args: (record, store) => {
+ const args = ['name', 'condition', 'threshold', 'severity', 'message', 'resetinterval', 'webhookids', 'cleanupwebhooks']
+ if (store.userInfo.roletype === 'Admin') args.push('email')
+ return args
+ },
+ mapping: {
+ condition: {
+ options: ['GT', 'GTE', 'LT', 'LTE', 'EQ']
+ },
+ severity: {
+ options: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
+ }
+ }
+ },
+ {
+ api: 'deleteResourceAlertRule',
+ icon: 'delete-outlined',
+ label: 'label.delete',
+ message: 'message.confirm.delete.resource.alert.rule',
+ dataView: true,
+ groupAction: true,
+ groupMap: (selection) => { return selection.map(x => { return { id: x.id } }) }
+ }
+ ]
+}
diff --git a/ui/src/config/section/monitoring.js b/ui/src/config/section/monitoring.js
new file mode 100644
index 000000000000..1bade98aa104
--- /dev/null
+++ b/ui/src/config/section/monitoring.js
@@ -0,0 +1,28 @@
+// 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.
+
+import resourceAlerts from '@/config/section/infra/resourceAlertRules'
+
+export default {
+ name: 'monitoring',
+ title: 'label.monitoring',
+ icon: 'BarChartOutlined',
+ permission: ['listResourceAlertRules'],
+ children: [
+ resourceAlerts
+ ]
+}
diff --git a/ui/src/config/section/storage.js b/ui/src/config/section/storage.js
index 75bdfd4d5fa6..d0ea953569e7 100644
--- a/ui/src/config/section/storage.js
+++ b/ui/src/config/section/storage.js
@@ -81,6 +81,12 @@ export default {
component: shallowRef(defineAsyncComponent(() => import('@/components/view/StatsTab.vue'))),
show: (record) => { return store.getters.features.instancesdisksstatsretentionenabled }
},
+ {
+ name: 'resourcealerts',
+ resourceType: 'Volume',
+ component: shallowRef(defineAsyncComponent(() => import('@/components/view/ResourceAlertsTab.vue'))),
+ show: () => { return 'listResourceAlerts' in store.getters.apis }
+ },
{
name: 'events',
resourceType: 'Volume',
diff --git a/ui/src/views/compute/InstanceTab.vue b/ui/src/views/compute/InstanceTab.vue
index d125995e3e1c..c4c53dc91993 100644
--- a/ui/src/views/compute/InstanceTab.vue
+++ b/ui/src/views/compute/InstanceTab.vue
@@ -107,6 +107,9 @@
+
+
+
@@ -163,6 +166,7 @@ import TooltipButton from '@/components/widgets/TooltipButton'
import ResourceIcon from '@/components/view/ResourceIcon'
import AnnotationsTab from '@/components/view/AnnotationsTab'
import VolumesTab from '@/components/view/VolumesTab.vue'
+import ResourceAlertsTab from '@/components/view/ResourceAlertsTab.vue'
import SecurityGroupSelection from '@views/compute/wizard/SecurityGroupSelection'
import GPUTab from '@/components/view/GPUTab.vue'
@@ -180,6 +184,7 @@ export default {
ResourceSchedules,
ListResourceTable,
SecurityGroupSelection,
+ ResourceAlertsTab,
TooltipButton,
ResourceIcon,
AnnotationsTab,
diff --git a/ui/src/views/resourcealert/CreateResourceAlertRule.vue b/ui/src/views/resourcealert/CreateResourceAlertRule.vue
new file mode 100644
index 000000000000..00cbe04f8a64
--- /dev/null
+++ b/ui/src/views/resourcealert/CreateResourceAlertRule.vue
@@ -0,0 +1,341 @@
+// 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.
+
+
+
+
+
+
+ {{ $t('label.name') }}
+
+
+
+
+ {{ $t('label.domain') }}
+
+ {{ d.path || d.name }}
+
+
+
+
+ {{ $t('label.account') }}
+
+ {{ a.name }}
+
+
+
+
+ {{ $t('label.resourcetype') }}
+
+ {{ resourceTypeLabels[rt] || rt }}
+
+
+
+
+ {{ $t('label.resource') }}
+
+ {{ $t('label.resource.alert.all.resources') }}
+ {{ r.displayname || r.name }}
+
+
+
+
+ {{ $t('label.metric') }}
+
+ {{ metricLabels[m] || m }}
+
+
+
+
+ {{ $t('label.condition') }}
+
+ {{ conditionLabels[c] || c }}
+
+
+
+
+ {{ $t('label.threshold') }}
+
+
+
+
+ {{ $t('label.severity') }}
+
+ {{ severityLabels[s] || s }}
+
+
+
+
+ {{ $t('label.message') }}
+
+
+
+
+ {{ $t('label.email') }}
+
+
+
+
+ {{ $t('label.resetinterval') }}
+
+
+
+
+ {{ $t('label.webhooks') }}
+
+ {{ wh.name }}
+
+
+
+
+
+
+
+
+
+
+