diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f6645f4af4d..3c86a9cea009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,7 @@ jobs: smoke/test_nested_virtualization smoke/test_set_sourcenat smoke/test_webhook_lifecycle + smoke/test_resource_alerts smoke/test_purge_expunged_vms smoke/test_extension_lifecycle smoke/test_extension_custom_action_lifecycle 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..4c9062992895 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,14 @@ 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 WEBHOOK_NAMES = "webhooknames"; + 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..2fa2c311d86b 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,11 @@ public interface VolumeDao extends GenericDao, StateDao findByAccount(long accountId); + /** + * Lists IDs of volumes that are detached or attached to user VMs, leaving out system VM and router volumes. + */ + List listUserVolumeIdsByAccountOrDomainsAndState(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..37cebf08ac00 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 @@ -99,6 +99,9 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol private static final String ORDER_POOLS_NUMBER_OF_VOLUMES_FOR_ACCOUNT_PART1 = "SELECT pool.id, SUM(IF(vol.state='Ready' AND vol.account_id = ?, 1, 0)) FROM `cloud`.`storage_pool` pool LEFT JOIN `cloud`.`volumes` vol ON pool.id = vol.pool_id WHERE pool.data_center_id = ? "; private static final String ORDER_POOLS_NUMBER_OF_VOLUMES_FOR_ACCOUNT_PART2 = " GROUP BY pool.id ORDER BY 2 ASC "; + private static final String LIST_USER_VOLUME_IDS = "SELECT vol.id FROM `cloud`.`volumes` vol " + + "LEFT JOIN `cloud`.`vm_instance` vm ON vm.id = vol.instance_id " + + "WHERE vol.removed IS NULL AND (vol.instance_id IS NULL OR vm.type = 'User')"; private static final String ORDER_ZONE_WIDE_POOLS_NUMBER_OF_VOLUMES_FOR_ACCOUNT = "SELECT pool.id, SUM(IF(vol.state='Ready' AND vol.account_id = ?, 1, 0)) FROM `cloud`.`storage_pool` pool LEFT JOIN `cloud`.`volumes` vol ON pool.id = vol.pool_id WHERE pool.data_center_id = ? " + " AND pool.scope = 'ZONE' AND pool.status='Up' " + " GROUP BY pool.id ORDER BY 2 ASC "; @@ -118,6 +121,47 @@ public List findByAccount(long accountId) { return listBy(sc); } + @Override + public List listUserVolumeIdsByAccountOrDomainsAndState(Long accountId, List domainIds, Volume.State state) { + if (domainIds != null && domainIds.isEmpty()) { + return new ArrayList<>(); + } + StringBuilder sql = new StringBuilder(LIST_USER_VOLUME_IDS); + if (accountId != null) { + sql.append(" AND vol.account_id = ?"); + } + if (domainIds != null) { + sql.append(" AND vol.domain_id IN (").append(String.join(",", Collections.nCopies(domainIds.size(), "?"))).append(")"); + } + if (state != null) { + sql.append(" AND vol.state = ?"); + } + List ids = new ArrayList<>(); + TransactionLegacy txn = TransactionLegacy.currentTxn(); + try (PreparedStatement pstmt = txn.prepareAutoCloseStatement(sql.toString())) { + int i = 1; + if (accountId != null) { + pstmt.setLong(i++, accountId); + } + if (domainIds != null) { + for (Long domainId : domainIds) { + pstmt.setLong(i++, domainId); + } + } + if (state != null) { + pstmt.setString(i, state.name()); + } + try (ResultSet rs = pstmt.executeQuery()) { + while (rs.next()) { + ids.add(rs.getLong(1)); + } + } + } catch (SQLException e) { + throw new CloudRuntimeException("Unable to list user volume IDs", e); + } + return ids; + } + @Override public List findIncludingRemovedByAccount(long accountId) { SearchCriteria sc = AllFieldsSearch.create(); 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/java/com/cloud/vm/dao/VmStatsDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDao.java index 0d7aa703a8cb..14f3829984ad 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDao.java @@ -41,6 +41,13 @@ public interface VmStatsDao extends GenericDao { */ List findByVmIdOrderByTimestampDesc(long vmId); + /** + * Finds the newest stats of a VM. + * @param vmId the VM ID. + * @return the newest stats, or null if there are none. + */ + VmStatsVO findLatestByVmId(long vmId); + /** * Finds stats by VM ID and timestamp >= a given time. * @param vmId the specific VM. diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDaoImpl.java index 327acec0c179..ab821f25017b 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmStatsDaoImpl.java @@ -85,6 +85,15 @@ public List findByVmIdOrderByTimestampDesc(long vmId) { return search(sc, orderByFilter, null, false); } + @Override + public VmStatsVO findLatestByVmId(long vmId) { + SearchCriteria sc = vmIdSearch.create(); + sc.setParameters("vmId", vmId); + Filter orderByFilter = new Filter(VmStatsVO.class, "timestamp", false, 0L, 1L); + List stats = search(sc, orderByFilter, null, false); + return stats.isEmpty() ? null : stats.get(0); + } + @Override public List findByVmIdAndTimestampGreaterThanEqual(long vmId, Date time) { SearchCriteria sc = vmIdTimestampGreaterThanEqualSearch.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..70f94952d6a8 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,64 @@ --; -- 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', + `state` varchar(32) NOT NULL DEFAULT 'Enabled' COMMENT 'Enabled or Disabled; disabled rules are not checked', + `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`), + INDEX `i_resource_alert_rules__resource_type__resource_id`(`resource_type`, `resource_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__resource_id__alert_timestamp`(`alert_rule_id`, `resource_id`, `alert_timestamp`), + INDEX `i_resource_alerts__resource_id__alert_timestamp`(`resource_id`, `alert_timestamp`), + 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; + +-- webhook_delivery: allow deliveries that are not tied to a stored event, such as resource alerts +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.webhook_delivery', 'event_id', 'event_id', 'bigint unsigned COMMENT "id of the event"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.webhook_delivery', 'event_type', 'varchar(255) COMMENT "type of the event when the delivery is not tied to a stored event"'); 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..4f97074e8387 --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.resource_alert_rule_view.sql @@ -0,0 +1,52 @@ +-- 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.state, + 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, + p.uuid project_uuid, + p.name project_name + 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 + LEFT JOIN `cloud`.`projects` p ON p.project_account_id = a.id; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.webhook_delivery_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.webhook_delivery_view.sql index 54ba52fba4a6..bb06495c4d39 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.webhook_delivery_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.webhook_delivery_view.sql @@ -28,9 +28,9 @@ CREATE VIEW `cloud`.`webhook_delivery_view` AS webhook_delivery.response, webhook_delivery.start_time, webhook_delivery.end_time, - event.id event_id, + webhook_delivery.event_id, event.uuid event_uuid, - event.type event_type, + IFNULL(event.type, webhook_delivery.event_type) event_type, webhook.id webhook_id, webhook.uuid webhook_uuid, webhook.name webhook_name, @@ -40,7 +40,7 @@ CREATE VIEW `cloud`.`webhook_delivery_view` AS mshost.name mshost_name FROM `cloud`.`webhook_delivery` - INNER JOIN + LEFT JOIN `cloud`.`event` ON webhook_delivery.event_id = event.id INNER JOIN `cloud`.`webhook` ON webhook_delivery.webhook_id = webhook.id diff --git a/engine/schema/src/test/java/com/cloud/storage/dao/VolumeDaoImplTest.java b/engine/schema/src/test/java/com/cloud/storage/dao/VolumeDaoImplTest.java index 6f153727ab76..c71ab82b66af 100644 --- a/engine/schema/src/test/java/com/cloud/storage/dao/VolumeDaoImplTest.java +++ b/engine/schema/src/test/java/com/cloud/storage/dao/VolumeDaoImplTest.java @@ -114,6 +114,32 @@ public void testListPoolIdsByVolumeCount_without_cluster_details() throws SQLExc verify(preparedStatementMock, times(1)).executeQuery(); } + @Test + public void listUserVolumeIdsByAccountOrDomainsAndStateSkipsSystemVmVolumes() throws SQLException { + final String expectedSql = "SELECT vol.id FROM `cloud`.`volumes` vol " + + "LEFT JOIN `cloud`.`vm_instance` vm ON vm.id = vol.instance_id " + + "WHERE vol.removed IS NULL AND (vol.instance_id IS NULL OR vm.type = 'User')" + + " AND vol.domain_id IN (?,?) AND vol.state = ?"; + when(TransactionLegacy.currentTxn()).thenReturn(transactionMock); + when(transactionMock.prepareAutoCloseStatement(expectedSql)).thenReturn(preparedStatementMock); + ResultSet rs = Mockito.mock(ResultSet.class); + when(rs.next()).thenReturn(true, false); + when(rs.getLong(1)).thenReturn(7L); + when(preparedStatementMock.executeQuery()).thenReturn(rs); + + List ids = volumeDao.listUserVolumeIdsByAccountOrDomainsAndState(null, List.of(1L, 2L), Volume.State.Ready); + + Assert.assertEquals(List.of(7L), ids); + verify(preparedStatementMock).setLong(1, 1L); + verify(preparedStatementMock).setLong(2, 2L); + verify(preparedStatementMock).setString(3, "Ready"); + } + + @Test + public void listUserVolumeIdsByAccountOrDomainsAndStateWithNoDomains() { + Assert.assertTrue(volumeDao.listUserVolumeIdsByAccountOrDomainsAndState(null, List.of(), Volume.State.Ready).isEmpty()); + } + @Test public void findByInstanceAndNotState_queriesWithInstanceIdAndExcludedStates() { SearchBuilder sb = Mockito.mock(SearchBuilder.class); diff --git a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDelivery.java b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDelivery.java index b24891539f9b..07416019b25f 100644 --- a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDelivery.java +++ b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDelivery.java @@ -27,7 +27,8 @@ public interface WebhookDelivery extends Identity, InternalIdentity { public static final String TEST_EVENT_TYPE = "TEST.WEBHOOK"; long getId(); - long getEventId(); + Long getEventId(); + String getEventType(); long getWebhookId(); long getManagementServerId(); String getHeaders(); diff --git a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDeliveryThread.java b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDeliveryThread.java index 9b67ba8eb031..49a5c7fd1243 100644 --- a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDeliveryThread.java +++ b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/WebhookDeliveryThread.java @@ -309,11 +309,13 @@ public static String generateHMACSignature(String data, String key) public static class WebhookDeliveryContext extends AsyncRpcContext { private final Long eventId; + private final String eventType; private final Long ruleId; - public WebhookDeliveryContext(AsyncCompletionCallback callback, Long eventId, Long ruleId) { + public WebhookDeliveryContext(AsyncCompletionCallback callback, Long eventId, String eventType, Long ruleId) { super(callback); this.eventId = eventId; + this.eventType = eventType; this.ruleId = ruleId; } @@ -321,6 +323,10 @@ public Long getEventId() { return eventId; } + public String getEventType() { + return eventType; + } + public Long getRuleId() { return ruleId; } 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..6b8d6f6e5836 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 @@ -114,20 +114,35 @@ static class DeliveryConfig { protected WebhookDeliveryThread getDeliveryJob(Event event, Webhook webhook, DeliveryConfig config) { WebhookDeliveryThread.WebhookDeliveryContext context = - new WebhookDeliveryThread.WebhookDeliveryContext<>(null, event.getEventId(), webhook.getId()); + new WebhookDeliveryThread.WebhookDeliveryContext<>(null, event.getEventId(), + event.getEventType(), webhook.getId()); AsyncCallbackDispatcher caller = AsyncCallbackDispatcher.create(this); caller.setCallback(caller.getTarget().deliveryCompleteCallback(null, null)) .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 +229,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); } @@ -241,13 +246,18 @@ protected Runnable getManualDeliveryJob(WebhookDelivery existingDelivery, Webhoo String eventUuid = UUID.randomUUID().toString(); String description = payload; String resourceAccountUuid = null; - if (existingDelivery != null) { + if (existingDelivery != null && existingDelivery.getEventId() != null) { EventJoinVO eventJoinVO = eventJoinDao.findById(existingDelivery.getEventId()); eventId = eventJoinVO.getId(); eventType = eventJoinVO.getType(); eventUuid = eventJoinVO.getUuid(); description = existingDelivery.getPayload(); resourceAccountUuid = eventJoinVO.getAccountUuid(); + } else if (existingDelivery != null) { + eventType = existingDelivery.getEventType(); + description = existingDelivery.getPayload(); + Account account = accountManager.getAccount(webhook.getAccountId()); + resourceAccountUuid = account.getUuid(); } else { Account account = accountManager.getAccount(webhook.getAccountId()); resourceAccountUuid = account.getUuid(); @@ -274,12 +284,48 @@ 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); + // Info level, as the caller picked these webhooks and would otherwise not know why nothing arrived. + if (webhook == null || !Webhook.State.Enabled.equals(webhook.getState())) { + logger.info("Skipping delivering {} to webhook ID: {} as it is missing or disabled", eventType, webhookId); + continue; + } + if (!isEventMatchingFilters(event, webhookFiltersCache.get(webhook.getId()))) { + logger.info("Skipping delivering {} to {} as it doesn't match the webhook filters", eventType, webhook); + continue; + } + WebhookDeliveryThread.WebhookDeliveryContext context = + new WebhookDeliveryThread.WebhookDeliveryContext<>(null, null, eventType, webhook.getId()); + AsyncCallbackDispatcher caller = + AsyncCallbackDispatcher.create(this); + caller.setCallback(caller.getTarget().deliveryCompleteCallback(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; + } + protected Void deliveryCompleteCallback( AsyncCallbackDispatcher callback, WebhookDeliveryThread.WebhookDeliveryContext context) { WebhookDeliveryThread.WebhookDeliveryResult result = callback.getResult(); - WebhookDeliveryVO deliveryVO = new WebhookDeliveryVO(context.getEventId(), context.getRuleId(), - ManagementServerNode.getManagementServerId(), result.getHeaders(), result.getPayload(), + WebhookDeliveryVO deliveryVO = new WebhookDeliveryVO(context.getEventId(), context.getEventType(), + context.getRuleId(), ManagementServerNode.getManagementServerId(), result.getHeaders(), result.getPayload(), result.isSuccess(), result.getResult(), result.getStarTime(), result.getEndTime()); webhookDeliveryDao.persist(deliveryVO); return null; @@ -386,6 +432,35 @@ public List listWebhooksByAccount(long accountId) { return webhookDao.listByAccount(accountId); } + @Override + public ControlledEntity findWebhookByUuid(String uuid) { + return webhookDao.findByUuid(uuid); + } + + @Override + public Pair describeWebhook(long webhookId) { + WebhookVO webhook = webhookDao.findById(webhookId); + return webhook != null ? new Pair<>(webhook.getUuid(), webhook.getName()) : 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); @@ -405,8 +480,8 @@ public WebhookDelivery executeWebhookDelivery(WebhookDelivery delivery, Webhook try { result = future.get(); if (delivery != null) { - webhookDeliveryVO = new WebhookDeliveryVO(delivery.getEventId(), delivery.getWebhookId(), - ManagementServerNode.getManagementServerId(), result.getHeaders(), result.getPayload(), + webhookDeliveryVO = new WebhookDeliveryVO(delivery.getEventId(), delivery.getEventType(), + delivery.getWebhookId(), ManagementServerNode.getManagementServerId(), result.getHeaders(), result.getPayload(), result.isSuccess(), result.getResult(), result.getStarTime(), result.getEndTime()); webhookDeliveryVO = webhookDeliveryDao.persist(webhookDeliveryVO); } else { diff --git a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryJoinVO.java b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryJoinVO.java index f0fb3e1cc9b1..48d08c997e2f 100644 --- a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryJoinVO.java +++ b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryJoinVO.java @@ -48,7 +48,7 @@ public class WebhookDeliveryJoinVO extends BaseViewVO implements InternalIdentit private String uuid; @Column(name = "event_id") - private long eventId; + private Long eventId; @Column(name = "event_uuid") private String eventUuid; @@ -107,7 +107,7 @@ public String getUuid() { return uuid; } - public long getEventId() { + public Long getEventId() { return eventId; } diff --git a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryVO.java b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryVO.java index e266ea5d7c4b..1a538c62ab2a 100644 --- a/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryVO.java +++ b/plugins/event-bus/webhook/src/main/java/org/apache/cloudstack/mom/webhook/vo/WebhookDeliveryVO.java @@ -46,7 +46,10 @@ public class WebhookDeliveryVO implements WebhookDelivery { private String uuid; @Column(name = "event_id") - private long eventId; + private Long eventId; + + @Column(name = "event_type") + private String eventType; @Column(name = "webhook_id") private long webhookId; @@ -85,10 +88,15 @@ public String getUuid() { } @Override - public long getEventId() { + public Long getEventId() { return eventId; } + @Override + public String getEventType() { + return eventType; + } + @Override public long getWebhookId() { return webhookId; @@ -138,10 +146,11 @@ public WebhookDeliveryVO() { this.uuid = UUID.randomUUID().toString(); } - public WebhookDeliveryVO(long eventId, long webhookId, long managementServerId, String headers, String payload, - boolean success, String response, Date startTime, Date endTime) { + public WebhookDeliveryVO(Long eventId, String eventType, long webhookId, long managementServerId, String headers, + String payload, boolean success, String response, Date startTime, Date endTime) { this.uuid = UUID.randomUUID().toString(); this.eventId = eventId; + this.eventType = eventType; this.webhookId = webhookId; this.mangementServerId = managementServerId; this.headers = headers; 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..ed81c05b187b 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; @@ -45,6 +46,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; @@ -500,6 +502,48 @@ public void deliveryCompleteCallbackPersistsDeliveryVO() { Mockito.verify(webhookDeliveryDao, Mockito.times(1)).persist(Mockito.any(WebhookDeliveryVO.class)); } + @Test + public void deliveryCompleteCallbackPersistsDeliveryWithoutStoredEvent() { + WebhookDeliveryThread.WebhookDeliveryResult result = Mockito.mock(WebhookDeliveryThread.WebhookDeliveryResult.class); + WebhookDeliveryThread.WebhookDeliveryContext context = + new WebhookDeliveryThread.WebhookDeliveryContext<>(null, null, "RESOURCE.ALERT", 456L); + Mockito.when(result.isSuccess()).thenReturn(false); + Mockito.when(result.getResult()).thenReturn("connection refused"); + AsyncCallbackDispatcher callback = Mockito.mock(AsyncCallbackDispatcher.class); + Mockito.when(callback.getResult()).thenReturn(result); + + webhookServiceImpl.deliveryCompleteCallback(callback, context); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WebhookDeliveryVO.class); + Mockito.verify(webhookDeliveryDao).persist(captor.capture()); + Assert.assertNull(captor.getValue().getEventId()); + Assert.assertEquals("RESOURCE.ALERT", captor.getValue().getEventType()); + Assert.assertEquals(456L, captor.getValue().getWebhookId()); + Assert.assertFalse(captor.getValue().isSuccess()); + } + + @Test + public void getManualDeliveryJobRedeliversDeliveryWithoutStoredEvent() { + WebhookDelivery existingDelivery = Mockito.mock(WebhookDelivery.class); + Webhook webhook = Mockito.mock(Webhook.class); + Account account = Mockito.mock(Account.class); + CompletableFuture future = Mockito.mock(CompletableFuture.class); + Mockito.when(existingDelivery.getEventId()).thenReturn(null); + Mockito.when(existingDelivery.getEventType()).thenReturn("RESOURCE.ALERT"); + Mockito.when(existingDelivery.getPayload()).thenReturn("{\"event\":\"RESOURCE.ALERT\"}"); + Mockito.when(webhook.getAccountId()).thenReturn(1L); + Mockito.when(accountManager.getAccount(1L)).thenReturn(account); + Mockito.when(account.getUuid()).thenReturn("account-uuid"); + + Runnable job = webhookServiceImpl.getManualDeliveryJob(existingDelivery, webhook, null, null, future); + + Event event = (Event) ReflectionTestUtils.getField(job, "event"); + Assert.assertEquals("RESOURCE.ALERT", event.getEventType()); + Assert.assertEquals("{\"event\":\"RESOURCE.ALERT\"}", event.getDescription()); + Assert.assertEquals("account-uuid", event.getResourceAccountUuid()); + Mockito.verify(eventJoinDao, Mockito.never()).findById(Mockito.anyLong()); + } + @Test public void manualDeliveryCompleteCallbackCompletesFuture() { WebhookDeliveryThread.WebhookDeliveryResult result = Mockito.mock(WebhookDeliveryThread.WebhookDeliveryResult.class); @@ -670,4 +714,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..728fbc93c2aa --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImpl.java @@ -0,0 +1,738 @@ +// 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.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.acl.ControlledEntity; +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.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.event.AlertGenerator; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.host.Host; +import com.cloud.host.HostStats; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.resource.ResourceState; +import com.cloud.server.ResourceTag; +import com.cloud.server.StatsCollector; +import com.cloud.storage.Storage; +import com.cloud.storage.StorageStats; +import com.cloud.storage.StoragePoolStatus; +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.AccountManager; +import com.cloud.user.AccountVO; +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 AccountManager accountManager; + @Inject DomainDao domainDao; + @Inject ManagementServerHostDao managementServerHostDao; + + private ScheduledExecutorService executor; + static final int EMAIL_QUEUE_SIZE = 100; + static final long COOLDOWN_TOLERANCE_MILLIS = 5000L; + + // One sender and a bounded queue, so a slow mail server or an alert storm can't pile up threads. + ExecutorService emailExecutor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(EMAIL_QUEUE_SIZE), r -> { + Thread t = new Thread(r, "ResourceAlertEmailSender"); + t.setDaemon(true); + return t; + }, (r, e) -> logger.warn("Dropping resource alert email as {} emails are already waiting to be sent", EMAIL_QUEUE_SIZE)); + + private final Map vmStatsCache = new HashMap<>(); + + 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 = getEvaluationInterval(); + 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; + } + + Integer configuredEvaluationInterval() { + return EVAL_INTERVAL.value(); + } + + int getEvaluationInterval() { + Integer interval = configuredEvaluationInterval(); + if (interval == null || interval < 1) { + logger.warn("Invalid {} value {}, using {} seconds", EVAL_INTERVAL.key(), interval, EVAL_INTERVAL.defaultValue()); + return Integer.parseInt(EVAL_INTERVAL.defaultValue()); + } + return interval; + } + + @Override + public boolean stop() { + if (executor != null) { + executor.shutdown(); + } + emailExecutor.shutdown(); + return true; + } + + @Override + public void evaluateRules() { + vmStatsCache.clear(); + try { + List rules = ruleDao.listActive(); + for (ResourceAlertRuleVO rule : rules) { + // One broken rule must not stop the others from being checked. + try { + if (isOrphaned(rule)) { + logger.info("Removing resource alert rule {} as its owner or resource is gone, or the owner lost access to the resource", rule.getUuid()); + alertDao.removeByAlertRuleId(rule.getId()); + ruleDao.remove(rule.getId()); + continue; + } + if (ResourceAlertRule.State.Disabled.equals(rule.getState())) { + continue; + } + evaluateRule(rule); + } catch (Exception e) { + logger.warn("Failed to evaluate resource alert rule {}", rule.getUuid(), e); + } + } + } finally { + vmStatsCache.clear(); + } + } + + // Several rules can watch the same VM, so its stats are read once per run. + private VmStats getVmStats(long vmId) { + if (!vmStatsCache.containsKey(vmId)) { + vmStatsCache.put(vmId, statsCollector.getRecentVmStats(vmId)); + } + return vmStatsCache.get(vmId); + } + + boolean isOrphaned(ResourceAlertRuleVO rule) { + AccountVO owner = accountDao.findById(rule.getAccountId()); + if (owner == null) { + return true; + } + Long resourceId = rule.getResourceId(); + if (resourceId == null) { + return false; + } + switch (rule.getResourceType()) { + case VirtualMachine: + return !ownerCanAccess(owner, userVmDao.findById(resourceId)); + case Volume: + return !ownerCanAccess(owner, volumeDao.findById(resourceId)); + case Host: + return hostDao.findById(resourceId) == null; + case StoragePool: + return storagePoolDao.findById(resourceId) == null; + default: + return false; + } + } + + // A VM can move to another account or its owner can leave a project, so access is checked on every run. + private boolean ownerCanAccess(Account owner, ControlledEntity resource) { + if (resource == null) { + return false; + } + try { + accountManager.checkAccess(owner, null, false, resource); + return true; + } catch (PermissionDeniedException e) { + return false; + } + } + + // Stopped VMs and hosts or pools out of service report stale or zero stats. + boolean isInService(ResourceAlertRule.ResourceType type, long resourceId) { + switch (type) { + case VirtualMachine: { + UserVmVO vm = userVmDao.findById(resourceId); + return vm != null && VirtualMachine.State.Running.equals(vm.getState()); + } + case Volume: { + VolumeVO volume = volumeDao.findById(resourceId); + return volume != null && Volume.State.Ready.equals(volume.getState()); + } + case Host: { + HostVO host = hostDao.findById(resourceId); + return host != null && Status.Up.equals(host.getStatus()) && !ResourceState.isMaintenanceState(host.getResourceState()); + } + case StoragePool: { + StoragePoolVO pool = storagePoolDao.findById(resourceId); + return pool != null && StoragePoolStatus.Up.equals(pool.getStatus()); + } + 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); + } + } + + void removeStaleWebhookLinks() { + int removed = ruleWebhookDao.removeLinksToRemovedWebhooks(); + if (removed > 0) { + logger.debug("Removed {} resource alert rule links to deleted webhooks", removed); + } + } + + class EvaluationTask extends ManagedContextRunnable { + @Override + protected void runInContext() { + GlobalLock lock = GlobalLock.getInternLock("ResourceAlertEvaluation"); + try { + if (!lock.lock(5)) { + return; + } + try { + if (isEvaluatingServer()) { + evaluateRules(); + removeExpiredAlerts(); + removeStaleWebhookLinks(); + } + } 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 (checksServiceState(rule) && !isInService(rule.getResourceType(), resourceId)) continue; + if (isGeneric) { + if (isOptedOut(rule.getResourceType(), resourceId)) continue; + if (ruleDao.existsSpecificRule(rule.getResourceType(), rule.getMetric(), resourceId, rule.getAccountId())) 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()); + } + + // All-resources VM and volume rules already list only running VMs and ready volumes. + private boolean checksServiceState(ResourceAlertRuleVO rule) { + return rule.getResourceId() != null + || ResourceAlertRule.ResourceType.Host.equals(rule.getResourceType()) + || ResourceAlertRule.ResourceType.StoragePool.equals(rule.getResourceType()); + } + + 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.listUserVolumeIdsByAccountOrDomainsAndState( + 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) { + if (!metric.appliesTo(type)) { + return null; + } + switch (metric) { + case CPU_UTILIZATION: + if (type == ResourceAlertRule.ResourceType.VirtualMachine) { + VmStats s = getVmStats(resourceId); + 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 = getVmStats(resourceId); + 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: { + VmStats s = getVmStats(resourceId); + return s != null ? s.getDiskReadIOs() : null; + } + case DISK_WRITE_IOPS: { + VmStats s = getVmStats(resourceId); + return s != null ? s.getDiskWriteIOs() : null; + } + case DISK_READ_KBPS: { + VmStats s = getVmStats(resourceId); + return s != null ? s.getDiskReadKBs() : null; + } + case DISK_WRITE_KBPS: { + VmStats s = getVmStats(resourceId); + return s != null ? s.getDiskWriteKBs() : null; + } + case NETWORK_READ_KBPS: { + if (type == ResourceAlertRule.ResourceType.Host) { + HostStats s = statsCollector.getHostStats(resourceId); + return s != null ? s.getNetworkReadKBs() : null; + } + VmStats s = getVmStats(resourceId); + 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 = getVmStats(resourceId); + 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_USED_GB: { + VolumeStats s = getVolumeStats(resourceId); + return s != null ? s.getPhysicalSize() / (1024.0 * 1024.0 * 1024.0) : null; + } + case VOLUME_UTILIZATION: { + VolumeStats s = getVolumeStats(resourceId); + if (s == null || s.getVirtualSize() <= 0) return null; + return ((double) s.getPhysicalSize() / s.getVirtualSize()) * 100.0; + } + 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; + } + + // Alert times are stored without milliseconds and runs drift a little, so a cooldown equal to + // the check interval would otherwise skip every other check. + boolean canFire(long ruleId, Long resourceId, int resetInterval) { + ResourceAlertVO last = alertDao.findLastFiredForRule(ruleId, resourceId); + if (last == null) return true; + long millisSinceLast = System.currentTimeMillis() - last.getAlertTimestamp().getTime(); + return millisSinceLast >= TimeUnit.SECONDS.toMillis(resetInterval) - COOLDOWN_TOLERANCE_MILLIS; + } + + 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); + + Pair resource = describeResource(rule.getResourceType(), resourceId); + String subject = buildSubject(rule, resource); + String body = buildBody(rule, resource, value); + long dcId = getDataCenterId(rule.getResourceType(), resourceId); + publishAlertEvent(dcId, subject, body); + deliverToWebhooks(rule, alert, resource, value); + + if (rule.isEmail()) { + sendEmail(subject, body); + } + + logger.info("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, Pair resource, 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, resource, value)); + } + + String buildWebhookPayload(ResourceAlertRuleVO rule, ResourceAlertVO alert, Pair resource, 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", resource != null ? resource.first() : null); + payload.addProperty("resourcename", resource != null ? resource.second() : null); + 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 Pair describeResource(ResourceAlertRule.ResourceType type, Long id) { + if (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; + } + } + + private String buildSubject(ResourceAlertRuleVO rule, Pair resource) { + 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(), + resource != null ? StringUtils.defaultIfBlank(resource.second(), resource.first()) : "unknown"); + } + + private String buildBody(ResourceAlertRuleVO rule, Pair resource, double value) { + StringBuilder sb = new StringBuilder(); + sb.append("Rule: ").append(rule.getName()).append('\n'); + AccountVO owner = accountDao.findByIdIncludingRemoved(rule.getAccountId()); + if (owner != null) { + sb.append("Account: ").append(owner.getAccountName()).append('\n'); + } + DomainVO domain = domainDao.findByIdIncludingRemoved(rule.getDomainId()); + if (domain != null) { + sb.append("Domain: ").append(domain.getPath()).append('\n'); + } + sb.append("Resource Type: ").append(rule.getResourceType().name()).append('\n'); + if (resource != null) { + sb.append("Resource: ").append(StringUtils.defaultString(resource.second())).append('\n'); + sb.append("Resource ID: ").append(resource.first()).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..21d25531ecbf --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertMetric.java @@ -0,0 +1,52 @@ +// 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), + DISK_WRITE_IOPS(ResourceAlertRule.ResourceType.VirtualMachine), + DISK_READ_KBPS(ResourceAlertRule.ResourceType.VirtualMachine), + DISK_WRITE_KBPS(ResourceAlertRule.ResourceType.VirtualMachine), + 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_USED_GB(ResourceAlertRule.ResourceType.Volume), + VOLUME_UTILIZATION(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 || this == VOLUME_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..d00ab4e8a63e --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertRule.java @@ -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. + +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 + } + + enum State { + Enabled, Disabled + } + + String getName(); + ResourceType getResourceType(); + Long getResourceId(); + String getMetric(); + AlertCondition getCondition(); + double getThreshold(); + AlertSeverity getSeverity(); + String getMessage(); + boolean isEmail(); + int getResetInterval(); + State getState(); + 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..83896bd28baf --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImpl.java @@ -0,0 +1,593 @@ +// 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.GlobalLock; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.dao.UserVmDao; + +import org.apache.cloudstack.context.CallContext; + +public class ResourceAlertServiceImpl extends ManagerBase implements ResourceAlertService { + + static final int OWNER_LOCK_WAIT_SECONDS = 30; + + @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(), cmd.getProjectId()); + + 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; + List webhookIds = resolveWebhookIds(owner, cmd.getWebhookIds()); + + // Held across management servers so parallel creates can't go over the limit or reuse a name. + GlobalLock lock = getOwnerLock(owner.getId()); + if (!lock.lock(OWNER_LOCK_WAIT_SECONDS)) { + throw new CloudRuntimeException("Unable to create the resource alert rule as another rule is being created for the account, please try again"); + } + ResourceAlertRuleVO rule; + try { + 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"); + } + String name = validateName(owner.getId(), cmd.getName(), null); + + rule = new ResourceAlertRuleVO( + name, resourceType, resourceId, + owner.getId(), domainId, + metric.name(), condition, cmd.getThreshold(), severity, + cmd.getMessage(), email, resetInterval); + ruleDao.persist(rule); + if (!webhookIds.isEmpty()) { + ruleWebhookDao.replaceWebhooksForRule(rule.getId(), webhookIds); + } + } finally { + lock.unlock(); + lock.releaseRef(); + } + CallContext.current().setEventResourceId(rule.getId()); + CallContext.current().setEventDetails("Rule: " + rule.getName()); + 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(), cmd.getProjectId(), + 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 (cmd.getName() != null) rule.setName(validateName(rule.getAccountId(), cmd.getName(), rule.getId())); + 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()); + } + if (StringUtils.isNotBlank(cmd.getState())) rule.setState(parseState(cmd.getState())); + 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()); + alertDao.removeByAlertRuleId(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 (StringUtils.isNotBlank(cmd.getKeyword())) { + List matchingRuleIds = ruleDao.listIdsByNameLike(cmd.getKeyword()); + if (alertRuleIds == null) { + alertRuleIds = new ArrayList<>(matchingRuleIds); + } else { + alertRuleIds.retainAll(matchingRuleIds); + } + } + String severity = StringUtils.isNotBlank(cmd.getSeverity()) ? parseSeverity(cmd.getSeverity()).name() : null; + if (alertRuleIds != null && alertRuleIds.isEmpty()) { + ListResponse empty = new ListResponse<>(); + empty.setResponses(new ArrayList<>(), 0); + return empty; + } + Pair, Integer> alerts = alertDao.searchAndCountByFilters( + alertRuleIds, resourceId, severity, 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.setState(vo.getState() != null ? vo.getState().name() : null); + List> webhooks = describeWebhooks(vo.getId()); + r.setWebhookIds(webhooks.stream().map(Pair::first).collect(Collectors.toList())); + // Left out when empty so the UI hides it + r.setWebhookNames(webhooks.isEmpty() ? null : webhooks.stream().map(Pair::second).collect(Collectors.toList())); + r.setAccountName(vo.getAccountName()); + r.setDomainId(vo.getDomainUuid()); + r.setDomainName(vo.getDomainName()); + r.setProjectId(vo.getProjectUuid()); + r.setProjectName(vo.getProjectName()); + 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> describeWebhooks(long ruleId) { + List ids = ruleWebhookDao.listWebhookIdsByRule(ruleId); + WebhookHelper webhookHelper = ids.isEmpty() ? null : getWebhookHelper(); + if (webhookHelper == null) { + return new ArrayList<>(); + } + return ids.stream().map(webhookHelper::describeWebhook).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"); + } + } + + GlobalLock getOwnerLock(long accountId) { + return GlobalLock.getInternLock("ResourceAlertRules.Account." + accountId); + } + + // Rules are picked by name in the UI and in alerts, so a name must be set and unique for its owner. + private String validateName(long accountId, String name, Long ruleId) { + String trimmed = StringUtils.trimToEmpty(name); + if (trimmed.isEmpty()) { + throw new InvalidParameterValueException("name cannot be blank"); + } + ResourceAlertRuleVO existing = ruleDao.findActiveByAccountIdAndName(accountId, trimmed); + if (existing != null && (ruleId == null || existing.getId() != ruleId)) { + throw new InvalidParameterValueException("A resource alert rule named " + trimmed + " already exists for this account"); + } + return trimmed; + } + + 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(), cmd.getProjectId(), + permittedAccounts, domainIdRecursiveListProject, cmd.listAll(), false); + if (permittedAccounts.isEmpty() && domainIdRecursiveListProject.first() == null + && domainIdRecursiveListProject.third() == 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.getEnumIgnoreCase(ResourceAlertRule.ResourceType.class, value); + if (type == null) { + throw new InvalidParameterValueException("Invalid resourcetype: " + value + ". Valid values: VirtualMachine, Volume, Host, StoragePool"); + } + return type; + } + + private ResourceAlertRule.State parseState(String value) { + ResourceAlertRule.State state = EnumUtils.getEnumIgnoreCase(ResourceAlertRule.State.class, value); + if (state == null) { + throw new InvalidParameterValueException("Invalid state: " + value + ". Valid values: Enabled, Disabled"); + } + return state; + } + + 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..7bcae07cfd5e --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/CreateResourceAlertRuleCmd.java @@ -0,0 +1,135 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.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; + + +@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", length = 4096) + 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; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, + entityType = org.apache.cloudstack.api.response.ProjectResponse.class, + description = "project to associate this rule with") + private Long projectId; + + 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; } + public Long getProjectId() { return projectId; } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + @Override + public void execute() throws ServerApiException { + 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); + } +} 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..edcbbd16ff61 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/command/user/DeleteResourceAlertRuleCmd.java @@ -0,0 +1,69 @@ +// 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; + + +@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 { + 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); + } +} 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..0b0931cdecfc --- /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.BaseListProjectAndAccountResourcesCmd; +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 BaseListProjectAndAccountResourcesCmd { + + @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..6469d978e3b2 --- /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.BaseListProjectAndAccountResourcesCmd; +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 BaseListProjectAndAccountResourcesCmd { + + @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..c42a1b305a90 --- /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; + + +@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", length = 4096) + 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.STATE, type = CommandType.STRING, + description = "Enabled or Disabled; a disabled rule is not checked and fires no alerts") + private String state; + + @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 String getState() { return state; } + 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 { + 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); + } +} 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..26489c8483ff --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/api/response/ResourceAlertRuleResponse.java @@ -0,0 +1,139 @@ +// 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.STATE) + @Param(description = "Enabled or Disabled; disabled rules are not checked") + private String state; + + @SerializedName(ApiConstants.WEBHOOK_IDS) + @Param(description = "UUIDs of webhooks the rule delivers alerts to") + private List webhookIds; + + @SerializedName(ApiConstants.WEBHOOK_NAMES) + @Param(description = "names of webhooks the rule delivers alerts to") + private List webhookNames; + + @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.PROJECT_ID) + @Param(description = "the project id of the rule") + private String projectId; + + @SerializedName(ApiConstants.PROJECT) + @Param(description = "the project name of the rule") + private String projectName; + + @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 setState(String state) { this.state = state; } + public void setWebhookIds(List webhookIds) { this.webhookIds = webhookIds; } + public void setWebhookNames(List webhookNames) { this.webhookNames = webhookNames; } + 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 setProjectId(String projectId) { this.projectId = projectId; } + public void setProjectName(String projectName) { this.projectName = projectName; } + 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..5aa748a597b6 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDao.java @@ -0,0 +1,41 @@ +// 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); + + int removeByAlertRuleId(long alertRuleId); +} 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..866adc0c9d4b --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertDaoImpl.java @@ -0,0 +1,113 @@ +// 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 int removeByAlertRuleId(long alertRuleId) { + SearchCriteria sc = alertRuleIdSearch.create(); + sc.setParameters("alertRuleId", alertRuleId); + 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..0a0faf95e2e8 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDao.java @@ -0,0 +1,46 @@ +// 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); + + ResourceAlertRuleVO findActiveByAccountIdAndName(long accountId, String name); + + List listIdsByResourceType(ResourceAlertRule.ResourceType resourceType); + + List listIdsByNameLike(String keyword); + + boolean existsSpecificRule(ResourceAlertRule.ResourceType resourceType, String metric, long resourceId, long accountId); +} 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..e8fddc4ed302 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleDaoImpl.java @@ -0,0 +1,157 @@ +// 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 activeByAccountAndNameSearch; + private final SearchBuilder specificRuleSearch; + private final SearchBuilder resourceTypeSearch; + private final SearchBuilder nameLikeSearch; + + 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(); + + activeByAccountAndNameSearch = createSearchBuilder(); + activeByAccountAndNameSearch.and("accountId", activeByAccountAndNameSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + activeByAccountAndNameSearch.and("name", activeByAccountAndNameSearch.entity().getName(), SearchCriteria.Op.EQ); + activeByAccountAndNameSearch.and("removed", activeByAccountAndNameSearch.entity().getRemoved(), SearchCriteria.Op.NULL); + activeByAccountAndNameSearch.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("accountId", specificRuleSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + specificRuleSearch.and("removed", specificRuleSearch.entity().getRemoved(), SearchCriteria.Op.NULL); + specificRuleSearch.and("state", specificRuleSearch.entity().getState(), SearchCriteria.Op.EQ); + specificRuleSearch.done(); + + resourceTypeSearch = createSearchBuilder(); + resourceTypeSearch.and("resourceType", resourceTypeSearch.entity().getResourceType(), SearchCriteria.Op.EQ); + resourceTypeSearch.done(); + + nameLikeSearch = createSearchBuilder(); + nameLikeSearch.and("name", nameLikeSearch.entity().getName(), SearchCriteria.Op.LIKE); + nameLikeSearch.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 ResourceAlertRuleVO findActiveByAccountIdAndName(long accountId, String name) { + SearchCriteria sc = activeByAccountAndNameSearch.create(); + sc.setParameters("accountId", accountId); + sc.setParameters("name", name); + return findOneBy(sc); + } + + @Override + public boolean existsSpecificRule(ResourceAlertRule.ResourceType resourceType, String metric, long resourceId, long accountId) { + SearchCriteria sc = specificRuleSearch.create(); + sc.setParameters("resourceType", resourceType); + sc.setParameters("metric", metric); + sc.setParameters("resourceId", resourceId); + sc.setParameters("accountId", accountId); + sc.setParameters("state", ResourceAlertRule.State.Enabled); + return getCount(sc) > 0; + } + + @Override + public List listIdsByNameLike(String keyword) { + SearchCriteria sc = nameLikeSearch.create(); + sc.setParameters("name", "%" + keyword + "%"); + return listBy(sc).stream().map(ResourceAlertRuleVO::getId).collect(Collectors.toList()); + } + + @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..1184cf071821 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDao.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.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); + + int removeLinksToRemovedWebhooks(); +} 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..d91692966ce2 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/dao/ResourceAlertRuleWebhookDaoImpl.java @@ -0,0 +1,82 @@ +// 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.sql.PreparedStatement; +import java.sql.SQLException; +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.TransactionLegacy; +import com.cloud.utils.db.TransactionStatus; +import com.cloud.utils.exception.CloudRuntimeException; + +public class ResourceAlertRuleWebhookDaoImpl extends GenericDaoBase + implements ResourceAlertRuleWebhookDao { + + // Webhooks are soft deleted, so the foreign key cascade never removes their links. + private static final String REMOVE_LINKS_TO_REMOVED_WEBHOOKS = "DELETE rw FROM `cloud`.`resource_alert_rules_webhook` rw " + + "JOIN `cloud`.`webhook` w ON w.id = rw.webhook_id WHERE w.removed IS NOT NULL"; + + 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)); + } + } + }); + } + + @Override + public int removeLinksToRemovedWebhooks() { + TransactionLegacy txn = TransactionLegacy.currentTxn(); + try (PreparedStatement pstmt = txn.prepareStatement(REMOVE_LINKS_TO_REMOVED_WEBHOOKS)) { + return pstmt.executeUpdate(); + } catch (SQLException e) { + throw new CloudRuntimeException("Unable to remove resource alert rule links to removed webhooks", e); + } + } +} 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..5af0ff19af14 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleJoinVO.java @@ -0,0 +1,160 @@ +// 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 = "state") + @Enumerated(value = EnumType.STRING) + private ResourceAlertRule.State state; + + @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; + + @Column(name = "project_uuid") + private String projectUuid; + + @Column(name = "project_name") + private String projectName; + + 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 ResourceAlertRule.State getState() { return state; } + 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; } + public String getProjectUuid() { return projectUuid; } + public String getProjectName() { return projectName; } + + @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..93c529daa2f8 --- /dev/null +++ b/plugins/resource-alerts/src/main/java/org/apache/cloudstack/resourcealert/vo/ResourceAlertRuleVO.java @@ -0,0 +1,162 @@ +// 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 = "state") + @Enumerated(value = EnumType.STRING) + private State state = State.Enabled; + + @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 State getState() { return state; } + @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 setState(State state) { this.state = state; } + 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..82c57889e97a --- /dev/null +++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertManagerImplTest.java @@ -0,0 +1,1140 @@ +// 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.anyBoolean; +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.doThrow; +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.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import org.apache.cloudstack.acl.ControlledEntity; +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.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.host.HostStats; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.resource.ResourceState; +import com.cloud.server.ResourceTag; +import com.cloud.server.StatsCollector; +import com.cloud.storage.Storage; +import com.cloud.storage.StoragePoolStatus; +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.AccountManager; +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 AccountManager accountManager; + @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); + UserVmVO vm = runningVm(); + lenient().when(userVmDao.findById(anyLong())).thenReturn(vm); + VolumeVO volume = mock(VolumeVO.class); + lenient().when(volume.getState()).thenReturn(Volume.State.Ready); + lenient().when(volumeDao.findById(anyLong())).thenReturn(volume); + HostVO host = mock(HostVO.class); + lenient().when(host.getStatus()).thenReturn(Status.Up); + lenient().when(host.getResourceState()).thenReturn(ResourceState.Enabled); + lenient().when(hostDao.findById(anyLong())).thenReturn(host); + StoragePoolVO pool = mock(StoragePoolVO.class); + lenient().when(pool.getStatus()).thenReturn(StoragePoolStatus.Up); + lenient().when(storagePoolDao.findById(anyLong())).thenReturn(pool); + } + + private UserVmVO runningVm() { + UserVmVO vm = mock(UserVmVO.class); + lenient().when(vm.getState()).thenReturn(VirtualMachine.State.Running); + return vm; + } + + 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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(101L)).thenReturn(stats); + when(statsCollector.getRecentVmStats(102L)).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.getRecentVmStats(VM_ID)).thenReturn(stats); + when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null); + + UserVmVO vm = runningVm(); + 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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).thenReturn(stats); + when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null); + UserVmVO vm = mock(UserVmVO.class); + when(vm.getUuid()).thenReturn("vm-uuid"); + when(vm.getDisplayName()).thenReturn("web-01"); + when(userVmDao.findByIdIncludingRemoved(VM_ID)).thenReturn(vm); + AccountVO account = mock(AccountVO.class); + when(account.getAccountName()).thenReturn("acme"); + when(accountDao.findByIdIncludingRemoved(1L)).thenReturn(account); + DomainVO domain = mock(DomainVO.class); + when(domain.getPath()).thenReturn("/acme/"); + when(domainDao.findByIdIncludingRemoved(1L)).thenReturn(domain); + + manager.evaluateRules(); + + verify(mailSender).sendMail(mailCaptor.capture()); + String subject = mailCaptor.getValue().getSubject(); + String body = mailCaptor.getValue().getContent().toString(); + assertTrue(subject.contains("HIGH")); + assertTrue(subject.contains("CPU_UTILIZATION")); + assertTrue(subject.contains("GT")); + assertTrue(subject.contains("VirtualMachine web-01")); + assertTrue(body.contains("Resource: web-01")); + assertTrue(body.contains("Resource ID: vm-uuid")); + assertTrue(body.contains("Account: acme")); + assertTrue(body.contains("Domain: /acme/")); + } + + @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.getRecentVmStats(VM_ID)).thenReturn(stats); + when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null); + + UserVmVO vm = runningVm(); + 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, 1L)) + .thenReturn(false); + + VmStats stats = mock(VmStats.class); + when(stats.getCPUUtilization()).thenReturn(85.0); + when(statsCollector.getRecentVmStats(VM_ID)).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, 1L)) + .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.getRecentVmStats(VM_ID)).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(), 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.getRecentVmStats(VM_ID)).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.getRecentVmStats(VM_ID)).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(vm.getDisplayName()).thenReturn("web-01"); + 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("web-01", payload.get("resourcename").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()).getRecentVmStats(anyLong()); + } + + @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_USED_GB", AlertCondition.GT, thresholdGb, + AlertSeverity.MEDIUM, null, false, 600); + } + + @Test + public void testVolumeUsedRuleUsesPhysicalSizeByPath() { + when(ruleDao.listActive()).thenReturn(Collections.singletonList(volumeSizeRule(10.0))); + VolumeVO vol = mock(VolumeVO.class); + when(vol.getFormat()).thenReturn(Storage.ImageFormat.QCOW2); + when(vol.getState()).thenReturn(Volume.State.Ready); + 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 testVolumeUtilizationIsUsedOverDiskSize() { + ResourceAlertRuleVO rule = new ResourceAlertRuleVO("vol-pct", ResourceAlertRule.ResourceType.Volume, + VOLUME_ID, 1L, 1L, "VOLUME_UTILIZATION", AlertCondition.GT, 50.0, + AlertSeverity.MEDIUM, null, false, 600); + when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule)); + VolumeVO vol = mock(VolumeVO.class); + when(vol.getFormat()).thenReturn(Storage.ImageFormat.QCOW2); + when(vol.getState()).thenReturn(Volume.State.Ready); + when(vol.getPath()).thenReturn("vol-path"); + when(volumeDao.findById(VOLUME_ID)).thenReturn(vol); + VolumeStats stats = mock(VolumeStats.class); + when(stats.getPhysicalSize()).thenReturn(6L * 1024 * 1024 * 1024); + when(stats.getVirtualSize()).thenReturn(8L * 1024 * 1024 * 1024); + when(statsCollector.getVolumeStats("vol-path")).thenReturn(stats); + + manager.evaluateRules(); + + verify(alertDao).persist(alertCaptor.capture()); + assertEquals(75.0, alertCaptor.getValue().getMetricValue(), 0.001); + } + + @Test + public void testVolumeUsedRuleUsesChainInfoForOva() { + when(ruleDao.listActive()).thenReturn(Collections.singletonList(volumeSizeRule(10.0))); + VolumeVO vol = mock(VolumeVO.class); + when(vol.getFormat()).thenReturn(Storage.ImageFormat.OVA); + when(vol.getState()).thenReturn(Volume.State.Ready); + 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_USED_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).listUserVolumeIdsByAccountOrDomainsAndState(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(alertDao).removeByAlertRuleId(rule.getId()); + 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()).getRecentVmStats(anyLong()); + } + + @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); + } + + @Test + public void testEvaluationIntervalFallsBackWhenBelowOne() { + doReturn(0).when(manager).configuredEvaluationInterval(); + assertEquals(60, manager.getEvaluationInterval()); + doReturn(-5).when(manager).configuredEvaluationInterval(); + assertEquals(60, manager.getEvaluationInterval()); + doReturn(null).when(manager).configuredEvaluationInterval(); + assertEquals(60, manager.getEvaluationInterval()); + } + + @Test + public void testEvaluationIntervalUsesValidSetting() { + doReturn(30).when(manager).configuredEvaluationInterval(); + assertEquals(30, manager.getEvaluationInterval()); + } + + @Test + public void testGenericRuleNotSkippedBySpecificRuleOfAnotherAccount() { + ResourceAlertRuleVO rule = vmCpuRule(null); + stubFiringVmCpuRule(rule); + stubOwner(Account.Type.NORMAL); + when(userVmDao.listIdsByAccountOrDomainsAndState(1L, null, VirtualMachine.State.Running)) + .thenReturn(Collections.singletonList(VM_ID)); + lenient().when(ruleDao.existsSpecificRule(ResourceAlertRule.ResourceType.VirtualMachine, "CPU_UTILIZATION", VM_ID, 2L)) + .thenReturn(true); + when(alertDao.findLastFiredForRule(anyLong(), eq(VM_ID))).thenReturn(null); + + manager.evaluateRules(); + + verify(ruleDao).existsSpecificRule(ResourceAlertRule.ResourceType.VirtualMachine, "CPU_UTILIZATION", VM_ID, 1L); + verify(alertDao).persist(any()); + } + + @Test + public void testSpecificRuleSkipsStoppedVm() { + ResourceAlertRuleVO rule = vmCpuRule(VM_ID); + stubFiringVmCpuRule(rule); + UserVmVO vm = mock(UserVmVO.class); + when(vm.getState()).thenReturn(VirtualMachine.State.Stopped); + when(userVmDao.findById(VM_ID)).thenReturn(vm); + + manager.evaluateRules(); + + verify(alertDao, never()).persist(any()); + } + + @Test + public void testIsInServiceChecksResourceState() { + HostVO host = mock(HostVO.class); + when(host.getStatus()).thenReturn(Status.Up); + when(host.getResourceState()).thenReturn(ResourceState.Maintenance); + when(hostDao.findById(HOST_ID)).thenReturn(host); + assertFalse(manager.isInService(ResourceAlertRule.ResourceType.Host, HOST_ID)); + + StoragePoolVO pool = mock(StoragePoolVO.class); + when(pool.getStatus()).thenReturn(StoragePoolStatus.Maintenance); + when(storagePoolDao.findById(POOL_ID)).thenReturn(pool); + assertFalse(manager.isInService(ResourceAlertRule.ResourceType.StoragePool, POOL_ID)); + + VolumeVO volume = mock(VolumeVO.class); + when(volume.getState()).thenReturn(Volume.State.Allocated); + when(volumeDao.findById(7L)).thenReturn(volume); + assertFalse(manager.isInService(ResourceAlertRule.ResourceType.Volume, 7L)); + + assertTrue(manager.isInService(ResourceAlertRule.ResourceType.VirtualMachine, VM_ID)); + } + + @Test + public void testRuleRemovedWhenOwnerLostAccessToResource() { + ResourceAlertRuleVO rule = vmCpuRule(VM_ID); + when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule)); + doThrow(new PermissionDeniedException("moved")).when(accountManager).checkAccess(any(Account.class), any(), anyBoolean(), any(ControlledEntity.class)); + + manager.evaluateRules(); + + verify(alertDao).removeByAlertRuleId(rule.getId()); + verify(ruleDao).remove(rule.getId()); + verify(alertDao, never()).persist(any()); + } + + @Test + public void testVolumeDiskRuleNeverUsesVmStats() { + ResourceAlertRuleVO rule = new ResourceAlertRuleVO("vol-io", ResourceAlertRule.ResourceType.Volume, + VOLUME_ID, 1L, 1L, "DISK_READ_IOPS", AlertCondition.GTE, 0.0, + AlertSeverity.LOW, null, false, 600); + when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule)); + VolumeVO vol = mock(VolumeVO.class); + when(vol.getState()).thenReturn(Volume.State.Ready); + when(volumeDao.findById(VOLUME_ID)).thenReturn(vol); + + manager.evaluateRules(); + + verify(statsCollector, never()).getRecentVmStats(anyLong()); + verify(alertDao, never()).persist(any()); + } + + @Test + public void testVmStatsReadOnceForRulesOnSameVm() { + ResourceAlertRuleVO cpuRule = vmCpuRule(VM_ID); + ResourceAlertRuleVO netRule = new ResourceAlertRuleVO("vm-net", ResourceAlertRule.ResourceType.VirtualMachine, + VM_ID, 1L, 1L, "NETWORK_READ_KBPS", AlertCondition.GT, 1000.0, + AlertSeverity.LOW, null, false, 600); + when(ruleDao.listActive()).thenReturn(Arrays.asList(cpuRule, netRule)); + VmStats stats = mock(VmStats.class); + when(statsCollector.getRecentVmStats(VM_ID)).thenReturn(stats); + + manager.evaluateRules(); + manager.evaluateRules(); + + verify(statsCollector, times(2)).getRecentVmStats(VM_ID); + } + + @Test + public void testEmailSenderIsBounded() { + ThreadPoolExecutor executor = (ThreadPoolExecutor) new ResourceAlertManagerImpl().emailExecutor; + assertEquals(1, executor.getMaximumPoolSize()); + assertEquals(ResourceAlertManagerImpl.EMAIL_QUEUE_SIZE, executor.getQueue().remainingCapacity()); + executor.shutdownNow(); + } + + @Test + public void testRemoveStaleWebhookLinks() { + when(ruleWebhookDao.removeLinksToRemovedWebhooks()).thenReturn(2); + + manager.removeStaleWebhookLinks(); + + verify(ruleWebhookDao).removeLinksToRemovedWebhooks(); + } + + @Test + public void testBrokenRuleDoesNotStopOtherRules() { + ResourceAlertRuleVO broken = new ResourceAlertRuleVO("broken", ResourceAlertRule.ResourceType.VirtualMachine, + VM_ID, 1L, 1L, "NO_SUCH_METRIC", AlertCondition.GT, 1.0, + AlertSeverity.LOW, null, false, 600); + when(ruleDao.listActive()).thenReturn(Arrays.asList(broken, vmCpuRule(VM_ID))); + VmStats stats = mock(VmStats.class); + when(stats.getCPUUtilization()).thenReturn(85.0); + when(statsCollector.getRecentVmStats(VM_ID)).thenReturn(stats); + + manager.evaluateRules(); + + verify(alertDao, times(1)).persist(any()); + } + + private ResourceAlertVO firedSecondsAgo(long seconds) { + ResourceAlertVO last = mock(ResourceAlertVO.class); + when(last.getAlertTimestamp()).thenReturn(new Date(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(seconds))); + return last; + } + + @Test + public void testCooldownEqualToCheckIntervalFiresOnNextCheck() { + ResourceAlertVO last = firedSecondsAgo(59); + when(alertDao.findLastFiredForRule(1L, VM_ID)).thenReturn(last); + + assertTrue(manager.canFire(1L, VM_ID, 60)); + } + + @Test + public void testCooldownStillBlocksWellBeforeItEnds() { + ResourceAlertVO last = firedSecondsAgo(50); + when(alertDao.findLastFiredForRule(1L, VM_ID)).thenReturn(last); + + assertFalse(manager.canFire(1L, VM_ID, 60)); + } + + @Test + public void testDisabledRuleIsNotChecked() { + ResourceAlertRuleVO rule = vmCpuRule(VM_ID); + rule.setState(ResourceAlertRule.State.Disabled); + when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule)); + + manager.evaluateRules(); + + verify(statsCollector, never()).getRecentVmStats(anyLong()); + verify(alertDao, never()).persist(any()); + } + + @Test + public void testDisabledRuleIsStillRemovedWhenResourceIsGone() { + ResourceAlertRuleVO rule = vmCpuRule(VM_ID); + rule.setState(ResourceAlertRule.State.Disabled); + when(ruleDao.listActive()).thenReturn(Collections.singletonList(rule)); + when(userVmDao.findById(VM_ID)).thenReturn(null); + + manager.evaluateRules(); + + verify(ruleDao).remove(rule.getId()); + } +} 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..659a744353eb --- /dev/null +++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertMetricTest.java @@ -0,0 +1,109 @@ +// 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 testDiskMetricsApplyToVmOnly() { + 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)); + assertFalse(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 testVolumeUsedAppliesToVolumeOnly() { + assertTrue(ResourceAlertMetric.VOLUME_USED_GB.appliesTo(ResourceAlertRule.ResourceType.Volume)); + assertFalse(ResourceAlertMetric.VOLUME_USED_GB.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine)); + assertFalse(ResourceAlertMetric.VOLUME_USED_GB.appliesTo(ResourceAlertRule.ResourceType.Host)); + assertFalse(ResourceAlertMetric.VOLUME_USED_GB.appliesTo(ResourceAlertRule.ResourceType.StoragePool)); + assertTrue(ResourceAlertMetric.VOLUME_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.Volume)); + assertFalse(ResourceAlertMetric.VOLUME_UTILIZATION.appliesTo(ResourceAlertRule.ResourceType.VirtualMachine)); + } + + @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()); + assertTrue(ResourceAlertMetric.VOLUME_UTILIZATION.isPercentage()); + assertFalse(ResourceAlertMetric.VOLUME_USED_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..7141835a9e63 --- /dev/null +++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/ResourceAlertServiceImplTest.java @@ -0,0 +1,733 @@ +// 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.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +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.InOrder; +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.utils.db.GlobalLock; +import com.cloud.utils.exception.CloudRuntimeException; +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; + private GlobalLock ownerLock; + + @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); + + ownerLock = mock(GlobalLock.class); + when(ownerLock.lock(anyInt())).thenReturn(true); + doReturn(ownerLock).when(service).getOwnerLock(anyLong()); + } + + @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); + verify(alertDao, never()).removeByAlertRuleId(1L); + } + + @Test + public void testDeleteRemovesAlertHistory() { + DeleteResourceAlertRuleCmd cmd = mock(DeleteResourceAlertRuleCmd.class); + when(cmd.getId()).thenReturn(1L); + ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class); + when(ruleDao.findById(1L)).thenReturn(rule); + when(ruleDao.remove(1L)).thenReturn(true); + + service.deleteResourceAlertRule(cmd); + + verify(alertDao).removeByAlertRuleId(1L); + verify(ruleDao).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")); + } + + @Test + public void testRuleResponseIncludesWebhookIdsAndNames() { + doReturn(webhookHelper).when(service).getWebhookHelper(); + ResourceAlertRuleJoinVO joined = mock(ResourceAlertRuleJoinVO.class); + when(joined.getId()).thenReturn(5L); + when(joined.getResourceType()).thenReturn(ResourceAlertRule.ResourceType.VirtualMachine); + when(ruleJoinDao.findById(Mockito.anyLong())).thenReturn(joined); + when(ruleWebhookDao.listWebhookIdsByRule(5L)).thenReturn(List.of(11L, 12L)); + when(webhookHelper.describeWebhook(11L)).thenReturn(new Pair<>("wh-1", "ops-hook")); + when(webhookHelper.describeWebhook(12L)).thenReturn(null); + + ResourceAlertRuleResponse response = service.createResourceAlertRule(validVmCreateCmd()); + + assertEquals(List.of("wh-1"), org.springframework.test.util.ReflectionTestUtils.getField(response, "webhookIds")); + assertEquals(List.of("ops-hook"), org.springframework.test.util.ReflectionTestUtils.getField(response, "webhookNames")); + } + + @Test + public void testRuleResponseLeavesOutWebhookNamesWhenNone() { + ResourceAlertRuleJoinVO joined = mock(ResourceAlertRuleJoinVO.class); + when(joined.getResourceType()).thenReturn(ResourceAlertRule.ResourceType.VirtualMachine); + when(ruleJoinDao.findById(Mockito.anyLong())).thenReturn(joined); + + ResourceAlertRuleResponse response = service.createResourceAlertRule(validVmCreateCmd()); + + assertEquals(List.of(), org.springframework.test.util.ReflectionTestUtils.getField(response, "webhookIds")); + assertNull(org.springframework.test.util.ReflectionTestUtils.getField(response, "webhookNames")); + } + + @Test + public void testCreateUsesProjectAsOwner() { + CreateResourceAlertRuleCmd cmd = validVmCreateCmd(); + when(cmd.getProjectId()).thenReturn(42L); + + service.createResourceAlertRule(cmd); + + verify(accountManager).finalizeOwner(eq(caller), any(), any(), eq(42L)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCreateFailsOnBlankName() { + CreateResourceAlertRuleCmd cmd = validVmCreateCmd(); + when(cmd.getName()).thenReturn(" "); + + service.createResourceAlertRule(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCreateFailsOnDuplicateName() { + when(ruleDao.findActiveByAccountIdAndName(42L, "cpu-high")).thenReturn(mock(ResourceAlertRuleVO.class)); + + service.createResourceAlertRule(validVmCreateCmd()); + } + + @Test + public void testCreateTrimsName() { + CreateResourceAlertRuleCmd cmd = validVmCreateCmd(); + when(cmd.getName()).thenReturn(" cpu-high "); + + service.createResourceAlertRule(cmd); + + assertEquals("cpu-high", persistedRuleCapture().getName()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateFailsOnDuplicateName() { + UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.getName()).thenReturn("taken"); + ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class); + when(rule.getId()).thenReturn(1L); + when(rule.getAccountId()).thenReturn(42L); + when(ruleDao.findById(1L)).thenReturn(rule); + ResourceAlertRuleVO other = mock(ResourceAlertRuleVO.class); + when(other.getId()).thenReturn(2L); + when(ruleDao.findActiveByAccountIdAndName(42L, "taken")).thenReturn(other); + + service.updateResourceAlertRule(cmd); + } + + @Test + public void testUpdateKeepsOwnName() { + UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.getName()).thenReturn("mine"); + when(cmd.getThreshold()).thenReturn(null); + when(cmd.getResetInterval()).thenReturn(null); + ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class); + when(rule.getId()).thenReturn(1L); + when(rule.getAccountId()).thenReturn(42L); + when(ruleDao.findById(1L)).thenReturn(rule); + when(ruleDao.findActiveByAccountIdAndName(42L, "mine")).thenReturn(rule); + + service.updateResourceAlertRule(cmd); + + verify(rule).setName("mine"); + } + + @Test + public void testCreateAcceptsResourceTypeInAnyCase() { + CreateResourceAlertRuleCmd cmd = validVmCreateCmd(); + when(cmd.getResourceType()).thenReturn("virtualmachine"); + + service.createResourceAlertRule(cmd); + + assertEquals(ResourceAlertRule.ResourceType.VirtualMachine, persistedRuleCapture().getResourceType()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testListAlertsFailsOnInvalidSeverity() { + ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class); + when(cmd.getDomainId()).thenReturn(null); + when(cmd.getSeverity()).thenReturn("bogus"); + + service.listResourceAlerts(cmd); + } + + @Test + public void testListAlertsNormalizesSeverity() { + ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class); + when(cmd.getDomainId()).thenReturn(null); + when(cmd.getSeverity()).thenReturn("high"); + when(alertDao.searchAndCountByFilters(any(), any(), eq("HIGH"), any(), any(), any(), any())) + .thenReturn(new Pair<>(List.of(), 0)); + + service.listResourceAlerts(cmd); + + verify(alertDao).searchAndCountByFilters(any(), any(), eq("HIGH"), any(), any(), any(), any()); + } + + @Test + public void testListAlertsKeywordWithNoMatchingRuleReturnsEmpty() { + ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class); + when(cmd.getDomainId()).thenReturn(null); + when(cmd.getKeyword()).thenReturn("zzzz"); + when(ruleDao.listIdsByNameLike("zzzz")).thenReturn(List.of()); + + ListResponse response = service.listResourceAlerts(cmd); + + assertEquals(Integer.valueOf(0), response.getCount()); + verify(alertDao, never()).searchAndCountByFilters(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + public void testListAlertsKeywordFiltersByRuleName() { + ListResourceAlertsCmd cmd = mock(ListResourceAlertsCmd.class); + when(cmd.getDomainId()).thenReturn(null); + when(cmd.getKeyword()).thenReturn("cpu"); + when(ruleDao.listIdsByNameLike("cpu")).thenReturn(List.of(5L, 6L)); + when(alertDao.searchAndCountByFilters(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new Pair<>(List.of(), 0)); + + service.listResourceAlerts(cmd); + + verify(alertDao).searchAndCountByFilters(eq(List.of(5L, 6L)), any(), any(), any(), any(), any(), any()); + } + + @Test + public void testCreateChecksLimitAndSavesUnderOwnerLock() { + service.createResourceAlertRule(validVmCreateCmd()); + + InOrder order = inOrder(ownerLock, ruleDao); + order.verify(ownerLock).lock(ResourceAlertServiceImpl.OWNER_LOCK_WAIT_SECONDS); + order.verify(ruleDao).countActiveByAccountId(42L); + order.verify(ruleDao).persist(any(ResourceAlertRuleVO.class)); + order.verify(ownerLock).unlock(); + verify(service).getOwnerLock(42L); + } + + @Test + public void testCreateReleasesOwnerLockWhenLimitReached() { + when(ruleDao.countActiveByAccountId(42L)).thenReturn(20); + + try { + service.createResourceAlertRule(validVmCreateCmd()); + } catch (InvalidParameterValueException e) { + verify(ownerLock).unlock(); + verify(ownerLock).releaseRef(); + return; + } + fail("Expected the rule limit to be enforced"); + } + + @Test(expected = CloudRuntimeException.class) + public void testCreateFailsWhenOwnerLockNotAcquired() { + when(ownerLock.lock(anyInt())).thenReturn(false); + + service.createResourceAlertRule(validVmCreateCmd()); + } + + private UpdateResourceAlertRuleCmd stateUpdateCmd(String state, ResourceAlertRuleVO rule) { + UpdateResourceAlertRuleCmd cmd = mock(UpdateResourceAlertRuleCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.getState()).thenReturn(state); + when(cmd.getThreshold()).thenReturn(null); + when(cmd.getResetInterval()).thenReturn(null); + when(ruleDao.findById(1L)).thenReturn(rule); + return cmd; + } + + @Test + public void testUpdateDisablesRule() { + ResourceAlertRuleVO rule = mock(ResourceAlertRuleVO.class); + + service.updateResourceAlertRule(stateUpdateCmd("disabled", rule)); + + verify(rule).setState(ResourceAlertRule.State.Disabled); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateFailsOnInvalidState() { + service.updateResourceAlertRule(stateUpdateCmd("Paused", mock(ResourceAlertRuleVO.class))); + } +} diff --git a/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/api/command/user/ResourceAlertRuleCmdTest.java b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/api/command/user/ResourceAlertRuleCmdTest.java new file mode 100644 index 000000000000..801aaecfee79 --- /dev/null +++ b/plugins/resource-alerts/src/test/java/org/apache/cloudstack/resourcealert/api/command/user/ResourceAlertRuleCmdTest.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.api.command.user; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.resourcealert.ResourceAlertService; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; + +@RunWith(MockitoJUnitRunner.class) +public class ResourceAlertRuleCmdTest { + + @Mock + ResourceAlertService resourceAlertService; + + @InjectMocks + CreateResourceAlertRuleCmd createCmd; + + @InjectMocks + UpdateResourceAlertRuleCmd updateCmd; + + @InjectMocks + DeleteResourceAlertRuleCmd deleteCmd; + + @Test(expected = InvalidParameterValueException.class) + public void createKeepsInvalidParameterError() { + when(resourceAlertService.createResourceAlertRule(any())).thenThrow(new InvalidParameterValueException("bad threshold")); + createCmd.execute(); + } + + @Test(expected = PermissionDeniedException.class) + public void updateKeepsPermissionError() { + when(resourceAlertService.updateResourceAlertRule(any())).thenThrow(new PermissionDeniedException("denied")); + updateCmd.execute(); + } + + @Test(expected = PermissionDeniedException.class) + public void deleteKeepsPermissionError() { + when(resourceAlertService.deleteResourceAlertRule(any())).thenThrow(new PermissionDeniedException("denied")); + deleteCmd.execute(); + } +} diff --git a/server/src/main/java/com/cloud/server/StatsCollector.java b/server/src/main/java/com/cloud/server/StatsCollector.java index 456792d14b75..30bc327b9cbb 100644 --- a/server/src/main/java/com/cloud/server/StatsCollector.java +++ b/server/src/main/java/com/cloud/server/StatsCollector.java @@ -1327,6 +1327,24 @@ protected void runInContext() { } } + /** + * Gets the newest stats collected from a given VM, ignoring stats older than three collection intervals. + * + * @param vmId the specific VM. + * @return the newest stats, or null if there are none or they are too old. + */ + public VmStats getRecentVmStats(long vmId) { + VmStatsVO latest = vmStatsDao.findLatestByVmId(vmId); + if (latest == null) { + return null; + } + long maxAge = 3 * Math.max(vmStatsInterval, ONE_MINUTE_IN_MILLISCONDS); + if (System.currentTimeMillis() - latest.getTimestamp().getTime() > maxAge) { + return null; + } + return gson.fromJson(latest.getVmStatsData(), VmStatsEntry.class); + } + /** * Gets the latest or the accumulation of the stats collected from a given VM. * 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..17d44e639299 100644 --- a/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java +++ b/server/src/main/java/org/apache/cloudstack/webhook/WebhookHelper.java @@ -21,8 +21,16 @@ import org.apache.cloudstack.acl.ControlledEntity; +import com.cloud.utils.Pair; + public interface WebhookHelper { void deleteWebhooksForAccount(long accountId); List listWebhooksByAccount(long accountId); + + ControlledEntity findWebhookByUuid(String uuid); + + Pair describeWebhook(long webhookId); + + void deliverToWebhooks(List webhookIds, long accountId, String eventType, String payload); } diff --git a/server/src/test/java/com/cloud/server/StatsCollectorTest.java b/server/src/test/java/com/cloud/server/StatsCollectorTest.java index cb00d1652c9a..d9cbb8dfb0e0 100644 --- a/server/src/test/java/com/cloud/server/StatsCollectorTest.java +++ b/server/src/test/java/com/cloud/server/StatsCollectorTest.java @@ -440,6 +440,31 @@ public void getVmStatsTestWithNullAccumulate() { Assert.assertEquals(vmStatsEntryMock, result); } + @Test + public void getRecentVmStatsTestReturnsNewestStats() { + VmStatsVO latest = new VmStatsVO(1L, 1L, new Date(), "{\"cpuUtilization\":42.0}"); + Mockito.doReturn(latest).when(vmStatsDaoMock).findLatestByVmId(1L); + + VmStats result = statsCollector.getRecentVmStats(1L); + + Assert.assertEquals(42.0, result.getCPUUtilization(), 0.001); + } + + @Test + public void getRecentVmStatsTestIgnoresOldStats() { + Date old = new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(10)); + Mockito.doReturn(new VmStatsVO(1L, 1L, old, "{\"cpuUtilization\":42.0}")).when(vmStatsDaoMock).findLatestByVmId(1L); + + Assert.assertNull(statsCollector.getRecentVmStats(1L)); + } + + @Test + public void getRecentVmStatsTestNoStats() { + Mockito.doReturn(null).when(vmStatsDaoMock).findLatestByVmId(1L); + + Assert.assertNull(statsCollector.getRecentVmStats(1L)); + } + @Test public void getLatestOrAccumulatedVmMetricsStatsTestAccumulate() { Mockito.doReturn(null).when(statsCollector).accumulateVmMetricsStats(Mockito.anyList()); diff --git a/test/integration/smoke/test_resource_alerts.py b/test/integration/smoke/test_resource_alerts.py new file mode 100644 index 000000000000..fd14158fd454 --- /dev/null +++ b/test/integration/smoke/test_resource_alerts.py @@ -0,0 +1,352 @@ +# 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. +""" BVT tests for resource alert rules and alert delivery +""" +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.lib.base import (Account, + Configurations, + Domain, + ResourceAlertRule, + ServiceOffering, + Tag, + VirtualMachine, + Webhook) +from marvin.lib.common import (get_domain, + get_zone, + get_suitable_test_template) +from marvin.lib.utils import random_gen +from marvin.codes import FAILED +from nose.plugins.attrib import attr +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import logging +import socket +import time +import _thread + +_multiprocess_shared_ = True +deliveries_received = [] + +# Rules are checked every resourcealert.evaluation.interval (60s by default) and VM stats every +# vm.stats.interval, so the first alert can take a few minutes after a rule is created. +ALERT_WAIT_SECONDS = 420 +POLL_SECONDS = 15 + + +class AlertReceiver(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers['Content-Length']) + body = self.rfile.read(length).decode('utf-8') + deliveries_received.append({'event': self.headers.get('X-CS-Event'), 'payload': body}) + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + + def log_message(self, *args): + pass + + +class TestResourceAlerts(cloudstackTestCase): + + original_config_values = {} + + @classmethod + def setUpClass(cls): + testClient = super(TestResourceAlerts, cls).getClsTestClient() + cls.apiclient = testClient.getApiClient() + cls.services = testClient.getParsedTestDataConfig() + cls.hypervisor = testClient.getHypervisorInfo() + cls.mgtSvrDetails = cls.config.__dict__["mgtSvr"][0].__dict__ + cls.logger = logging.getLogger('TestResourceAlerts') + cls.logger.setLevel(logging.DEBUG) + cls._cleanup = [] + + cls.zone = get_zone(cls.apiclient, testClient.getZoneForTests()) + cls.root_domain = get_domain(cls.apiclient) + cls.template = get_suitable_test_template(cls.apiclient, cls.zone.id, cls.services["ostype"], cls.hypervisor) + if cls.template == FAILED: + assert False, "get_suitable_test_template() failed to return template" + cls.services["small"]["zoneid"] = cls.zone.id + + cls.start_receiver() + cls.manage_configurations() + + cls.domain = Domain.create(cls.apiclient, cls.services["domain"], parentdomainid=cls.root_domain.id) + cls._cleanup.append(cls.domain) + cls.user1 = Account.create(cls.apiclient, cls.services["account"], domainid=cls.domain.id) + cls._cleanup.append(cls.user1) + cls.user2 = Account.create(cls.apiclient, cls.services["account"], domainid=cls.domain.id) + cls._cleanup.append(cls.user2) + cls.domain_admin = Account.create(cls.apiclient, cls.services["account"], admin=True, domainid=cls.domain.id) + cls._cleanup.append(cls.domain_admin) + cls.user1_api = testClient.getUserApiClient(UserName=cls.user1.name, DomainName=cls.domain.name) + cls.user2_api = testClient.getUserApiClient(UserName=cls.user2.name, DomainName=cls.domain.name) + cls.domain_admin_api = testClient.getUserApiClient(UserName=cls.domain_admin.name, DomainName=cls.domain.name, + type=2) + + cls.service_offering = ServiceOffering.create(cls.apiclient, cls.services["service_offerings"]["tiny"]) + cls._cleanup.append(cls.service_offering) + cls.vm1 = cls.deploy_vm(cls.user1) + cls.vm2 = cls.deploy_vm(cls.user2) + + @classmethod + def tearDownClass(cls): + if cls.server: + cls.server.socket.close() + cls.manage_configurations(restore=True) + super(TestResourceAlerts, cls).tearDownClass() + + @classmethod + def deploy_vm(cls, account): + vm = VirtualMachine.create( + cls.apiclient, + cls.services["small"], + templateid=cls.template.id, + accountid=account.name, + domainid=account.domainid, + serviceofferingid=cls.service_offering.id, + mode=cls.zone.networktype + ) + cls._cleanup.append(vm) + return vm + + @classmethod + def start_receiver(cls): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect((cls.mgtSvrDetails["mgtSvrIp"], cls.mgtSvrDetails["port"])) + server_ip = s.getsockname()[0] + s.close() + s = socket.socket() + s.bind(('', 0)) + port = s.getsockname()[1] + s.close() + cls.receiver_url = "http://%s:%d" % (server_ip, port) + cls.server = HTTPServer(('0.0.0.0', port), AlertReceiver) + _thread.start_new_thread(lambda server: server.serve_forever(), (cls.server,)) + cls.logger.debug("Alert receiver running at %s" % cls.receiver_url) + + @classmethod + def manage_configurations(cls, restore=False): + updates = { + "webhook.delivery.allow.http": "true", + "webhook.delivery.blocklist": "1.2.3.4/32" + } + if restore: + for name, value in cls.original_config_values.items(): + if value is not None: + Configurations.update(cls.apiclient, name=name, value=value) + cls.original_config_values.clear() + return + for name, value in updates.items(): + configs = Configurations.list(cls.apiclient, name=name) + cls.original_config_values[name] = configs[0].value if configs else None + Configurations.update(cls.apiclient, name=name, value=value) + + def setUp(self): + self.cleanup = [] + + def tearDown(self): + super(TestResourceAlerts, self).tearDown() + + def create_rule(self, apiclient, **kwargs): + rule = ResourceAlertRule.create(apiclient, name="Test-" + random_gen(), **kwargs) + self.cleanup.append(rule) + return rule + + def forget(self, item): + self.cleanup = [x for x in self.cleanup if x.id != item.id] + + def wait_for(self, check, what): + waited = 0 + while waited < ALERT_WAIT_SECONDS: + result = check() + if result: + return result + time.sleep(POLL_SECONDS) + waited += POLL_SECONDS + self.fail("Timed out after %ds waiting for %s" % (ALERT_WAIT_SECONDS, what)) + + def alerts_of(self, rule, apiclient=None): + return rule.list_alerts(apiclient or self.apiclient, listall=True) or [] + + def assertApiFails(self, fn, message): + with self.assertRaises(Exception) as ctx: + fn() + self.assertIn(message, str(ctx.exception)) + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="false") + def test_01_rule_lifecycle(self): + """Create, list, update, disable, enable and delete a rule on one VM""" + rule = self.create_rule(self.apiclient, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GT", threshold=80, severity="HIGH", + resetinterval=300) + self.assertEqual(rule.resourceid, self.vm1.id) + self.assertEqual(rule.resourcename, self.vm1.displayname) + self.assertEqual(rule.resetinterval, 300) + + rules = ResourceAlertRule.list(self.apiclient, id=rule.id) + self.assertEqual(len(rules), 1, "Rule should be listed") + + updated = rule.update(self.apiclient, threshold=90, severity="CRITICAL") + self.assertEqual(updated.threshold, 90) + self.assertEqual(updated.severity, "CRITICAL") + self.assertEqual(updated.state, "Enabled") + + paused = rule.update(self.apiclient, state="Disabled") + self.assertEqual(paused.state, "Disabled") + self.assertEqual(rule.update(self.apiclient, state="Enabled").state, "Enabled") + + rule.delete(self.apiclient) + self.forget(rule) + self.assertIsNone(ResourceAlertRule.list(self.apiclient, id=rule.id), "Deleted rule should not be listed") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="false") + def test_02_invalid_input(self): + """Bad thresholds are refused""" + self.assertApiFails(lambda: self.create_rule(self.apiclient, resourcetype="VirtualMachine", + metric="CPU_UTILIZATION", condition="GT", threshold=150, + severity="LOW"), + "percentage") + self.assertApiFails(lambda: self.create_rule(self.apiclient, resourcetype="VirtualMachine", + metric="NETWORK_READ_KBPS", condition="GT", threshold=-1, + severity="LOW"), + "zero or more") + self.assertApiFails(lambda: self.create_rule(self.apiclient, resourcetype="Volume", + metric="CPU_UTILIZATION", condition="GT", threshold=50, + severity="LOW"), + "CPU_UTILIZATION") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="false") + def test_03_user_limits(self): + """Users can only use their own VMs and Volumes, no hosts, pools or email""" + self.assertApiFails(lambda: self.create_rule(self.user1_api, resourcetype="Host", metric="CPU_UTILIZATION", + condition="GT", threshold=90, severity="LOW"), + "Only root admins") + self.assertApiFails(lambda: self.create_rule(self.user1_api, resourcetype="VirtualMachine", + metric="CPU_UTILIZATION", condition="GT", threshold=90, + severity="LOW", email=True), + "Only root admins") + self.assertApiFails(lambda: self.create_rule(self.user1_api, resourcetype="VirtualMachine", + resourceid=self.vm2.id, metric="CPU_UTILIZATION", + condition="GT", threshold=90, severity="LOW"), + "permission") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="false") + def test_04_users_cannot_see_or_change_other_rules(self): + """A user cannot list, update or delete another user's rule""" + rule = self.create_rule(self.user1_api, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GT", threshold=90, severity="LOW") + self.assertIsNone(ResourceAlertRule.list(self.user2_api, id=rule.id), "Other user's rule must not be listed") + self.assertApiFails(lambda: rule.update(self.user2_api, threshold=50), "permission") + self.assertApiFails(lambda: rule.delete(self.user2_api), "permission") + self.assertEqual(ResourceAlertRule.list(self.apiclient, id=rule.id)[0].threshold, 90) + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="false") + def test_05_domain_admin_scope(self): + """A domain admin sees rules in their domain but not root admin rules""" + user_rule = self.create_rule(self.user1_api, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GT", threshold=90, severity="LOW") + admin_rule = self.create_rule(self.apiclient, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GT", threshold=90, severity="LOW") + ids = [r.id for r in (ResourceAlertRule.list(self.domain_admin_api, listall=True) or [])] + self.assertIn(user_rule.id, ids) + self.assertNotIn(admin_rule.id, ids) + self.assertApiFails(lambda: self.create_rule(self.domain_admin_api, resourcetype="StoragePool", + metric="STORAGE_UTILIZATION", condition="GT", threshold=90, + severity="LOW"), + "Only root admins") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="true") + def test_06_alert_fires_and_is_delivered_to_webhook(self): + """A firing rule saves an alert and sends it to its webhook""" + webhook = Webhook.create(self.user1_api, name="Test-" + random_gen(), payloadurl=self.receiver_url) + self.cleanup.append(webhook) + rule = self.create_rule(self.user1_api, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GTE", threshold=0, severity="LOW", + webhookids=webhook.id) + self.assertEqual(rule.webhookids, [webhook.id]) + + alerts = self.wait_for(lambda: self.alerts_of(rule, self.user1_api), "the rule to fire") + self.assertEqual(alerts[0].resourceid, self.vm1.id) + self.assertEqual(alerts[0].metrictype, "CPU_UTILIZATION") + + def received(): + for d in deliveries_received: + if d['event'] == 'RESOURCE.ALERT' and json.loads(d['payload']).get('ruleid') == rule.id: + return json.loads(d['payload']) + return None + payload = self.wait_for(received, "the alert to reach the webhook") + self.assertEqual(payload['resourceid'], self.vm1.id) + self.assertEqual(payload["resourcename"], self.vm1.displayname) + self.assertEqual(payload['severity'], "LOW") + + deliveries = webhook.list_deliveries(self.user1_api, eventtype="RESOURCE.ALERT") or [] + self.assertTrue(len(deliveries) > 0, "Alert delivery should be recorded on the webhook") + self.assertTrue(deliveries[0].success, "Alert delivery should be successful") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="true") + def test_07_all_resources_rule_scope_and_opt_out(self): + """An all-resources rule covers only what its owner can see, and skips opted out VMs""" + user_rule = self.create_rule(self.user1_api, resourcetype="VirtualMachine", metric="NETWORK_WRITE_KBPS", + condition="GTE", threshold=0, severity="LOW") + Tag.create(self.apiclient, resourceIds=self.vm2.id, resourceType="UserVm", + tags={"resource.alert.opt.out": "true"}) + try: + admin_rule = self.create_rule(self.apiclient, resourcetype="VirtualMachine", + metric="NETWORK_WRITE_KBPS", condition="GTE", threshold=0, severity="LOW") + self.wait_for(lambda: self.alerts_of(user_rule, self.user1_api), "the user rule to fire") + self.wait_for(lambda: [a for a in self.alerts_of(admin_rule) if a.resourceid == self.vm1.id], + "the admin rule to fire on the user VM") + self.assertEqual({a.resourceid for a in self.alerts_of(user_rule, self.user1_api)}, {self.vm1.id}, + "A user rule must only cover the user's own VMs") + self.assertNotIn(self.vm2.id, {a.resourceid for a in self.alerts_of(admin_rule)}, + "An opted out VM must be skipped by all-resources rules") + finally: + Tag.delete(self.apiclient, resourceIds=self.vm2.id, resourceType="UserVm", + tags={"resource.alert.opt.out": "true"}) + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="true") + def test_08_deleting_rule_removes_its_alerts(self): + """Deleting a rule also removes its alert history""" + rule = self.create_rule(self.apiclient, resourcetype="VirtualMachine", resourceid=self.vm1.id, + metric="CPU_UTILIZATION", condition="GTE", threshold=0, severity="LOW") + self.wait_for(lambda: self.alerts_of(rule), "the rule to fire") + rule.delete(self.apiclient) + self.forget(rule) + left = ResourceAlertRule.list_alerts_for_resource(self.apiclient, "VirtualMachine", self.vm1.id, + listall=True) or [] + self.assertEqual([a for a in left if a.alertruleid == rule.id], [], + "Alerts of a deleted rule should be removed") + + @attr(tags=["advanced", "basic", "smoke"], required_hardware="true") + def test_09_rule_removed_when_vm_expunged(self): + """A rule on a VM goes away when the VM is expunged""" + vm = VirtualMachine.create( + self.apiclient, + self.services["small"], + templateid=self.template.id, + accountid=self.user1.name, + domainid=self.user1.domainid, + serviceofferingid=self.service_offering.id, + mode=self.zone.networktype + ) + rule = self.create_rule(self.user1_api, resourcetype="VirtualMachine", resourceid=vm.id, + metric="CPU_UTILIZATION", condition="GT", threshold=99, severity="LOW") + vm.delete(self.apiclient, expunge=True) + self.wait_for(lambda: ResourceAlertRule.list(self.apiclient, id=rule.id) is None, + "the rule to be removed after the VM was expunged") + self.forget(rule) diff --git a/tools/marvin/marvin/lib/base.py b/tools/marvin/marvin/lib/base.py index e7fa2f763db5..c60f72ae3f36 100755 --- a/tools/marvin/marvin/lib/base.py +++ b/tools/marvin/marvin/lib/base.py @@ -7585,6 +7585,63 @@ def delete_deliveries(self, apiclient, **kwargs): [setattr(cmd, k, v) for k, v in list(kwargs.items())] return apiclient.deleteWebhookDelivery(cmd) +class ResourceAlertRule: + """Manage Resource Alert Rule Life cycle""" + + def __init__(self, items): + self.__dict__.update(items) + + @classmethod + def create(cls, apiclient, name, resourcetype, metric, condition, threshold, severity, **kwargs): + """Create Resource Alert Rule""" + cmd = createResourceAlertRule.createResourceAlertRuleCmd() + cmd.name = name + cmd.resourcetype = resourcetype + cmd.metric = metric + cmd.condition = condition + cmd.threshold = threshold + cmd.severity = severity + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return ResourceAlertRule(apiclient.createResourceAlertRule(cmd).__dict__) + + @classmethod + def list(cls, apiclient, **kwargs): + cmd = listResourceAlertRules.listResourceAlertRulesCmd() + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + if 'account' in list(kwargs.keys()) and 'domainid' in list(kwargs.keys()): + cmd.listall = True + return apiclient.listResourceAlertRules(cmd) + + def update(self, apiclient, **kwargs): + """Update Resource Alert Rule""" + cmd = updateResourceAlertRule.updateResourceAlertRuleCmd() + cmd.id = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.updateResourceAlertRule(cmd) + + def delete(self, apiclient): + """Delete Resource Alert Rule""" + cmd = deleteResourceAlertRule.deleteResourceAlertRuleCmd() + cmd.id = self.id + apiclient.deleteResourceAlertRule(cmd) + + def list_alerts(self, apiclient, **kwargs): + """List fired alerts of this rule""" + cmd = listResourceAlerts.listResourceAlertsCmd() + cmd.alertruleid = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.listResourceAlerts(cmd) + + @classmethod + def list_alerts_for_resource(cls, apiclient, resourcetype, resourceid, **kwargs): + """List fired alerts of a resource""" + cmd = listResourceAlerts.listResourceAlertsCmd() + cmd.resourcetype = resourcetype + cmd.resourceid = resourceid + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.listResourceAlerts(cmd) + + class Extension: """Manage Extension Life cycle""" diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 99bf2cf7aef9..6b7022c3d1f7 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,31 @@ "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.cooldown.default": "Leave empty to use the default", +"label.resource.alert.owner.self": "Your own account", +"label.alertrulename": "Alert rule", +"label.webhookids": "Webhooks", +"label.webhooknames": "Webhooks", +"label.cleanupwebhooks": "Remove all webhooks", +"label.alerttimestamp": "Alert Time", +"label.metrictype": "Metric", +"label.metricvalue": "Value", +"message.confirm.delete.resource.alert.rule": "Please confirm that you want to delete this alert rule. Its alert history is deleted too.", +"label.enable.resource.alert.rule": "Enable alert rule", +"label.disable.resource.alert.rule": "Disable alert rule", +"message.confirm.enable.resource.alert.rule": "Please confirm that you want to enable this alert rule.", +"message.confirm.disable.resource.alert.rule": "Please confirm that you want to disable this alert rule. It will not fire alerts until it is enabled again.", "label.routingmode": "Routing mode", "label.routing.policy": "Routing policy", "label.routing.policy.terms": "Routing policy terms", diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index d2aabacb10c9..8a127f866370 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -93,6 +93,9 @@ {{ sizeInGiB(dataResource.chainsize) }} GiB +
+ {{ resourceAlertLabel(item, dataResource[item]) }} +
{{ $t(dataResource[item].toLowerCase()) }} {{ dataResource[item] }} @@ -144,7 +147,7 @@
{{ dataResource.cniconfigname }}
-
+
{{ dataResource[item].join(', ') }}
{{ dataResource[item] }}
@@ -263,6 +266,7 @@ import ObjectListTable from '@/components/view/ObjectListTable' import ExternalConfigurationDetails from '@/views/extension/ExternalConfigurationDetails' import TooltipButton from '@/components/widgets/TooltipButton' import { genericCompare } from '@/utils/sort' +import { resourceAlertLabel } from '@/views/resourcealert/resourceAlertOptions' import CodeHighlight from 'vue-code-highlight/src/CodeHighlight.vue' import 'vue-code-highlight/themes/prism-okaidia.css' @@ -466,6 +470,7 @@ export default { } }, methods: { + resourceAlertLabel, decodeUserData (userdata) { const decodedData = Buffer.from(userdata, 'base64') return decodedData.toString('utf-8') 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/ResourceAlertsTab.vue b/ui/src/components/view/ResourceAlertsTab.vue new file mode 100644 index 000000000000..e35351e853ca --- /dev/null +++ b/ui/src/components/view/ResourceAlertsTab.vue @@ -0,0 +1,159 @@ +// 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. + + + + diff --git a/ui/src/components/view/SearchView.vue b/ui/src/components/view/SearchView.vue index 7cd5db96f483..b58c2b54b190 100644 --- a/ui/src/components/view/SearchView.vue +++ b/ui/src/components/view/SearchView.vue @@ -172,6 +172,7 @@ import TooltipButton from '@/components/widgets/TooltipButton' import ResourceIcon from '@/components/view/ResourceIcon' import Status from '@/components/widgets/Status' import { i18n } from '@/locales' +import { RESOURCE_TYPE_LABELS } from '@/views/resourcealert/resourceAlertOptions' export default { name: 'SearchView', @@ -342,6 +343,8 @@ export default { type = 'list' } else if (item === 'tags') { type = 'tag' + } else if (item === 'resourcetype' && this.isResourceAlertsView()) { + type = 'list' } else if (['resourcetype', 'apikeyaccess'].includes(item)) { type = 'autocomplete' } else if (item === 'isencrypted') { @@ -469,6 +472,9 @@ export default { { value: 'Volume' }, { value: 'QuotaTariff' } ] + if (this.isResourceAlertsView()) { + this.fields[resourceTypeIndex].opts = Object.entries(RESOURCE_TYPE_LABELS).map(([id, name]) => ({ id, name })) + } this.fields[resourceTypeIndex].loading = false } @@ -929,6 +935,9 @@ export default { } }) }, + isResourceAlertsView () { + return this.$route.path.startsWith('/resourcealerts') + }, initFormFieldData () { const arrayField = this.initFields() 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..77a810ed647b --- /dev/null +++ b/ui/src/config/section/infra/resourceAlertRules.js @@ -0,0 +1,98 @@ +// 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' +import { i18n } from '@/locales' +import { resourceAlertLabel } from '@/views/resourcealert/resourceAlertOptions' + +const label = (field) => ({ [field]: (record) => resourceAlertLabel(field, record[field]) }) + +export default { + name: 'resourcealerts', + title: 'label.resource.alert.rules', + icon: 'BellOutlined', + permission: ['listResourceAlertRules'], + columns: () => { + const cols = ['name', label('resourcetype'), { + resourcename: (record) => record.resourceid ? record.resourcename : i18n.global.t('label.resource.alert.all.resources') + }, label('metric'), label('condition'), 'threshold', label('severity'), 'state'] + if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) cols.push('account') + return cols + }, + details: ['name', 'id', 'resourcetype', 'resourcename', 'resourceid', 'metric', 'condition', 'threshold', 'severity', 'state', 'message', 'email', 'resetinterval', 'webhooknames', 'account', 'project', 'domain', 'created'], + searchFilters: ['name', 'resourcetype'], + tabs: [{ + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, { + name: 'firedalerts', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/ResourceAlertsTab.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, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/resourcealert/EditResourceAlertRule.vue'))) + }, + { + api: 'updateResourceAlertRule', + icon: 'play-circle-outlined', + label: 'label.enable.resource.alert.rule', + message: 'message.confirm.enable.resource.alert.rule', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { state: 'Enabled' }, + groupMap: (selection) => { return selection.map(x => { return { id: x, state: 'Enabled' } }) }, + show: (record) => { return record.state === 'Disabled' } + }, + { + api: 'updateResourceAlertRule', + icon: 'pause-circle-outlined', + label: 'label.disable.resource.alert.rule', + message: 'message.confirm.disable.resource.alert.rule', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { state: 'Disabled' }, + groupMap: (selection) => { return selection.map(x => { return { id: x, state: 'Disabled' } }) }, + show: (record) => { return record.state !== 'Disabled' } + }, + { + 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 } }) } + } + ] +} 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..8d9b072959aa --- /dev/null +++ b/ui/src/views/resourcealert/CreateResourceAlertRule.vue @@ -0,0 +1,310 @@ +// 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. + + + + + + diff --git a/ui/src/views/resourcealert/EditResourceAlertRule.vue b/ui/src/views/resourcealert/EditResourceAlertRule.vue new file mode 100644 index 000000000000..6e3b57638757 --- /dev/null +++ b/ui/src/views/resourcealert/EditResourceAlertRule.vue @@ -0,0 +1,192 @@ +// 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. + + + + + + diff --git a/ui/src/views/resourcealert/resourceAlertOptions.js b/ui/src/views/resourcealert/resourceAlertOptions.js new file mode 100644 index 000000000000..82c991714193 --- /dev/null +++ b/ui/src/views/resourcealert/resourceAlertOptions.js @@ -0,0 +1,78 @@ +// 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. + +export const METRICS_BY_TYPE = { + VirtualMachine: ['CPU_UTILIZATION', 'MEMORY_UTILIZATION', 'DISK_READ_IOPS', 'DISK_WRITE_IOPS', 'DISK_READ_KBPS', 'DISK_WRITE_KBPS', 'NETWORK_READ_KBPS', 'NETWORK_WRITE_KBPS'], + Host: ['CPU_UTILIZATION', 'MEMORY_UTILIZATION', 'LOAD_AVERAGE', 'NETWORK_READ_KBPS', 'NETWORK_WRITE_KBPS'], + Volume: ['VOLUME_USED_GB', 'VOLUME_UTILIZATION'], + StoragePool: ['STORAGE_UTILIZATION', 'STORAGE_USED_IOPS'] +} + +export const CONDITIONS = ['GT', 'GTE', 'LT', 'LTE', 'EQ'] + +export const SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] + +export const RESOURCE_TYPE_LABELS = { + VirtualMachine: 'Virtual Machine', + Host: 'Host', + Volume: 'Volume', + StoragePool: 'Storage Pool' +} + +export const METRIC_LABELS = { + CPU_UTILIZATION: 'CPU Utilization %', + MEMORY_UTILIZATION: 'Memory Utilization %', + DISK_READ_IOPS: 'Disk Read IOPS', + DISK_WRITE_IOPS: 'Disk Write IOPS', + DISK_READ_KBPS: 'Disk Read KB/s', + DISK_WRITE_KBPS: 'Disk Write KB/s', + NETWORK_READ_KBPS: 'Network In KB/s', + NETWORK_WRITE_KBPS: 'Network Out KB/s', + STORAGE_UTILIZATION: 'Storage Utilization %', + LOAD_AVERAGE: 'Load Average', + VOLUME_USED_GB: 'Volume Used (GB)', + VOLUME_UTILIZATION: 'Volume Used %', + STORAGE_USED_IOPS: 'Storage Used IOPS' +} + +export const CONDITION_LABELS = { + GT: 'Is above', + GTE: 'Is above or equal to', + LT: 'Is below', + LTE: 'Is below or equal to', + EQ: 'Equals' +} + +export const SEVERITY_LABELS = { + CRITICAL: 'Critical', + HIGH: 'High', + MEDIUM: 'Medium', + LOW: 'Low' +} + +const LABELS_BY_FIELD = { + resourcetype: RESOURCE_TYPE_LABELS, + metric: METRIC_LABELS, + metrictype: METRIC_LABELS, + condition: CONDITION_LABELS, + severity: SEVERITY_LABELS +} + +export function resourceAlertLabel (field, value) { + const labels = LABELS_BY_FIELD[field] + return (labels && labels[value]) || value +}