Adds action groups to alerts that already exist, matched by scope and condition. Targets
hashicorp/azurerm ~> 4.0.
- βοΈ Creates one
azurerm_monitor_alert_processing_rule_action_group, namedthis. - π΄ This rule acts on alerts it does not own β produced by other alert rules, possibly created long after this rule exists, which record nothing about it.
- 𧬠The provider's eleven condition blocks are supplied as one map and rendered here, because eleven near-identical hand-written blocks is where a typo hides.
β οΈ The operators are not uniform: seven fields take four operators, four take two. Enforced per field.β οΈ Four fields have closed value sets, including nineteenmonitor_servicenames β several containing spaces.- βΉοΈ An empty
conditionsmap means every alert in scope, and this module says so rather than leaving it implicit. - β
Its sibling is schema-identical apart from one argument β see
terraform-azurerm-monitor-alert-processing-rule-suppression.
π‘ Why it matters:
scopesis not a snapshot β it is the rule's reach for as long as the rule exists. A subscription-scoped rule with no conditions picks up every future resource automatically, which for an action-group rule is often exactly right and should still be a decision rather than an accident.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
This diagram is shared with terraform-azurerm-monitor-alert-processing-rule-suppression, because the two
resources are schema-identical apart from one argument. Both modules embed the same validated diagram; the node
text carries the difference β this module adds notifications, the sibling removes them.
flowchart TB
RG["terraform-azurerm-resource-group -- holds the RULE record only. Unrelated to what the rule acts on"]
SCOPES["scopes -- a SUBSCRIPTION, a resource group or a single resource. The breadth is the whole story, and it is updatable in place"]
AGR["terraform-azurerm-monitor-alert-processing-rule-action-group -- ADDS action groups to matching alerts"]
SUP["terraform-azurerm-monitor-alert-processing-rule-suppression -- SUPPRESSES notifications for matching alerts"]
AG["terraform-azurerm-monitor-action-group -- consumed by ID, and ONLY by the action-group rule"]
ALERTS["alerts raised by OTHER alert rules -- rules neither module references, which may be created long after the rule exists and record nothing about it"]
PEOPLE["whoever the action groups notify"]
RG -->|"name"| AGR
RG -->|"name"| SUP
SCOPES -->|"which alerts are in reach"| AGR
SCOPES -->|"which alerts are in reach"| SUP
AG -->|"id as add_action_group_ids"| AGR
ALERTS -->|"intercepted by"| AGR
ALERTS -->|"intercepted by"| SUP
AGR -->|"adds notifications, so a mistake ANNOUNCES itself"| PEOPLE
SUP -->|"removes notifications, so a mistake is SILENT"| PEOPLE
style AGR fill:#0078D4,stroke:#004578,color:#ffffff
style SUP fill:#0078D4,stroke:#004578,color:#ffffff
style ALERTS fill:#004578,stroke:#00335c,color:#ffffff
style RG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style SCOPES fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style AG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style PEOPLE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
flowchart TB
IN_ID["name plus resource_group_name -- force-new. The resource group holds the RULE, not what it acts on"]
IN_SCOPES["scopes -- REQUIRED and updatable in place. Breadth is parsed and reported: subscription, resource_group or resource"]
IN_AG["add_action_group_ids -- REQUIRED, anchored to Microsoft.Insights/actionGroups. This is the ACTION, and it is ADDITIVE"]
IN_COND["conditions -- ONE map keyed by field name, rendered into the provider's ELEVEN separate blocks. Keys checked CASE-SENSITIVELY because an unknown key would be silently dropped"]
IN_SCHED["schedule -- effective window plus REPEATING daily, weekly and monthly recurrence. Two different time formats, and daily requires both times while the others do not"]
IN_EN["enabled -- defaults true in the provider and mirrored explicitly here"]
THIS["azurerm_monitor_alert_processing_rule_action_group.this"]
ALERTS["alerts from OTHER alert rules, for as long as this rule exists -- including on resources that do not exist yet"]
OUT_ID["id then name then enabled"]
OUT_SCOPE["scope_breadth, subscription_wide_scopes, has_subscription_wide_scope, matches_every_alert_in_scope"]
OUT_COND["active_condition_fields, condition_count, conditions_are_combined_with_and, condition_operators_are_not_uniform_across_fields"]
OUT_SCHED["is_always_in_force, has_open_ended_effective_window, recurrence_window_count, uses_multiple_recurrence_kinds"]
OUT_FLAG["this_rule_changes_other_peoples_alerts, the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action, accepts_no_credential"]
IN_ID --> THIS
IN_SCOPES --> THIS
IN_AG --> THIS
IN_COND --> THIS
IN_SCHED --> THIS
IN_EN --> THIS
THIS -->|"intercepts and adds action groups to"| ALERTS
THIS --> OUT_ID
THIS --> OUT_SCOPE
THIS --> OUT_COND
THIS --> OUT_SCHED
THIS --> OUT_FLAG
style THIS fill:#0078D4,stroke:#004578,color:#ffffff
style ALERTS fill:#004578,stroke:#00335c,color:#ffffff
style IN_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IN_SCOPES fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IN_AG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IN_COND fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IN_SCHED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IN_EN fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style OUT_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style OUT_SCOPE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style OUT_COND fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style OUT_SCHED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style OUT_FLAG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Resource inventory
| Resource / block | Count | Notes |
|---|---|---|
azurerm_monitor_alert_processing_rule_action_group.this |
1 | The keystone. |
condition |
0β1 | Holds up to eleven single-instance sub-blocks, rendered from one map. |
schedule |
0β1 | With a recurrence block whose three kinds are repeating. |
| The alerts it acts on | many | Owned by other alert rules. Not in this state. |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None. The caller configures the provider, its authentication, and its mandatory features {} block. |
| Module type | standalone |
| Keystone | azurerm_monitor_alert_processing_rule_action_group.this |
| ARM type | Microsoft.AlertsManagement/actionRules |
Schema notes that bite
- π΄ The ARM type is
actionRulesβ the older name for what the portal and provider call alert processing rules. And it isMicrosoft.AlertsManagement, while the action groups it adds live underMicrosoft.Insights. Two different resource providers in one resource. - π΄ Eleven condition fields, and the operators differ by field. Seven accept
Equals,NotEquals,Contains,DoesNotContain; four accept onlyEqualsandNotEquals. β οΈ monitor_servicehas nineteen published values, several containing spaces β"ActivityLog Administrative"is one value, and"VM Insights - Health"keeps its spaces.β οΈ The provider documents an at-least-one-of rule across the eleven condition fields. This module satisfies it by omitting theconditionblock entirely when the map is empty.β οΈ dailyrequires both times;weeklyandmonthlydo not. That asymmetry is the provider's and is mirrored exactly here.β οΈ Two time formats.effective_from/effective_untilareYYYY-MM-DDTHH:MM:SS; recurrence times areHH:MM:SS.β οΈ time_zonetakes WINDOWS zone names, published as a reference table rather than an enforceable set.- βΉοΈ
scopesis NOT force-new, so a rule's reach can be widened in place. Onlynameandresource_group_nameare force-new. - βΉοΈ
enableddefaults totrue. Mirrored explicitly in this module.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Microsoft.AlertsManagement/actionRules/write |
The rule's resource group | Create and update |
| The Terraform identity | Microsoft.AlertsManagement/actionRules/read |
The rule | Refresh and plan |
| The Terraform identity | Microsoft.AlertsManagement/actionRules/delete |
The rule | Destroy |
| The Terraform identity | read on each scopes entry |
Those scopes | Azure validates the rule's targets |
| The Terraform identity | Microsoft.Insights/actionGroups/read |
Each action group | To reference it |
β Plan access on this module is read-only in the useful sense. No computed secret-bearing attribute exists, and everything it handles is an identifier or a matching rule. Re-derived per resource, not inherited.
π΄ But the reach is larger than the permissions table suggests. Whoever can edit this configuration can change how alerts behave across an entire subscription, for teams who will never see it.
actionRules/writein one resource group is enough to affect alerts everywhere named inscopes.
Microsoft.AlertsManagementregistered on the subscription.- An existing resource group for the rule record.
- At least one scope β a subscription, resource group or resource ID that already exists.
- At least one existing Action Group, under
Microsoft.Insights/actionGroups. - Alert rules that actually fire. This resource processes alerts; it does not create them, and it does nothing at all if nothing in scope raises an alert.
terraform-azurerm-monitor-alert-processing-rule-action-group/
βββ providers.tf # required_version >= 1.12.0; azurerm ~> 4.0. No provider block.
βββ variables.tf # 10 typed inputs, 25 validations.
βββ main.tf # One keystone `this`; eleven condition blocks rendered from one map.
βββ outputs.tf # id first, then scope breadth, the condition facts, and the schedule facts.
βββ README.md # This document.
βββ SCOPE.md # The cross-module contract.
βββ LICENSE # MIT.
βββ .gitignore # The canonical library ignore file.
provider "azurerm" {
features {}
}
module "oncall_everything" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-oncall-sev01"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0", "Sev1"] }
}
}Consumes
| Input | Type | Source module |
|---|---|---|
resource_group_name |
Resource group name | terraform-azurerm-resource-group β name |
scopes |
Subscription / RG / resource IDs | Whatever the rule should cover |
add_action_group_ids |
Action Group Resource IDs | terraform-azurerm-monitor-action-group β id |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Rule Resource ID | Audit |
scope_breadth |
Per-scope breadth classification | check blocks |
has_subscription_wide_scope |
Reach | check blocks |
matches_every_alert_in_scope |
No conditions supplied | check blocks |
active_condition_fields |
Which of the eleven are in force | Review |
this_rule_changes_other_peoples_alerts |
Constant true |
Read this one |
The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:
variable "daytime_team_action_group_id" {
description = "id of an existing daytime team action group that these examples reference but do not create."
type = string
}
variable "dba_action_group_id" {
description = "id of an existing dba action group that these examples reference but do not create."
type = string
}
variable "migration_team_action_group_id" {
description = "id of an existing migration team action group that these examples reference but do not create."
type = string
}
variable "web_vm_id" {
description = "id of an existing web vm that these examples reference but do not create."
type = string
}1 Β· Page the on-call team for high-severity alerts
module "oncall_sev01" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-oncall-sev01"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0", "Sev1"] }
}
}β This is the canonical use of an action-group rule: one place that says "whatever fires at Sev0 or Sev1 anywhere in this subscription, page the on-call team" β without editing every alert rule.
βΉοΈ The action groups are ADDED, not substituted. Alerts keep whatever their own rules specify and gain these as well.
β οΈ Subscription scope is deliberate here andhas_subscription_wide_scopewill reporttrue. For this intent that is correct; the output exists so it is a decision rather than a surprise.
2 Β· The eleven condition fields, from one map
module "targeted_rule" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-db-platform"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]
add_action_group_ids = [var.dba_action_group_id]
conditions = {
severity = { operator = "Equals", values = ["Sev0", "Sev1", "Sev2"] }
monitor_condition = { operator = "Equals", values = ["Fired"] }
monitor_service = { operator = "Equals", values = ["Platform"] }
alert_rule_name = { operator = "Contains", values = ["sql", "database"] }
}
}𧬠The provider defines eleven separate single-instance blocks inside
condition. This module takes one map keyed by field name and renders each β the eleven legal keys arealert_context,alert_rule_id,alert_rule_name,description,monitor_condition,monitor_service,severity,signal_type,target_resource,target_resource_group,target_resource_type.
π΄ The key check is case-sensitive and does not trim. This module routes each entry into the provider block of the same name, so
"Severity"would match nothing and be silently dropped β the condition would not exist, with no error from Terraform or Azure. Rejecting the key at plan time is the only place that mistake can be caught.
β οΈ Conditions are ANDed; values within a field are ORed. The example above matches an alert that is Sev0β2 and firing and from Platform and whose rule name contains "sql" or "database".
3 Β· The operators are not uniform
module "mixed_operators" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-mixed"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
# Four operators available: free-form text and Resource IDs.
alert_rule_name = { operator = "DoesNotContain", values = ["canary"] }
target_resource = { operator = "Contains", values = ["/virtualMachines/"] }
# Only Equals and NotEquals: closed value sets.
severity = { operator = "NotEquals", values = ["Sev4"] }
signal_type = { operator = "Equals", values = ["Metric"] }
}
}
β οΈ Seven fields acceptEquals,NotEquals,ContainsandDoesNotContainβalert_context,alert_rule_id,alert_rule_name,description,target_resource,target_resource_group,target_resource_typeβ because they hold free-form text or Resource IDs.
π΄ Four accept only
EqualsandNotEqualsβmonitor_condition,monitor_service,severity,signal_typeβ because each has a closed set of values and there is nothing to take a substring of.severitywithContainsis rejected at plan time.
β The split is enforced as two checks, one per operator class, rather than applying the wider set everywhere and letting Azure reject it later.
4 Β· The four closed value sets
module "closed_sets" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-closed-sets"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0"] }
signal_type = { operator = "Equals", values = ["Metric", "Log"] }
monitor_condition = { operator = "Equals", values = ["Fired"] }
# Note the SPACES inside these values -- each is one value, not two.
monitor_service = {
operator = "Equals"
values = ["ActivityLog Administrative", "Application Insights", "VM Insights - Health"]
}
}
}
β οΈ severityisSev0βSev4β not"0", not"Critical".Sev0is the most severe.
β οΈ signal_typeisMetric,Log,Unknown,Health;monitor_conditionisFired,Resolved. All capitalised exactly as shown; a wrong-case value is rejected by the same closed-set check.
π΄
monitor_servicehas nineteen published values and several contain spaces."ActivityLog Administrative"is a single value, and"VM Insights - Health"keeps the spaces around its hyphen. Splitting one into two produces two invalid values.
βΉοΈ The other seven fields take free-form values, so this module validates only their operator β it does not invent constraints on an alert rule's name or description.
5 Β· An unconditional rule, said out loud
module "everything_to_oncall" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-catch-all"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
# No conditions: every alert in scope.
conditions = {}
}
output "reach" {
value = {
everything = module.everything_to_oncall.matches_every_alert_in_scope # true
sub_wide = module.everything_to_oncall.has_subscription_wide_scope # true
}
}βΉοΈ An empty map means every alert in scope, and this module expresses that by omitting the provider's
conditionblock entirely β which satisfies its documented at-least-one-of rule by omission rather than by violating it.
β For an action-group rule this is a legitimate and common intent: add an on-call group to everything. It is reported rather than discouraged.
β οΈ But note what "everything in scope" includes: resources that do not exist yet. A subscription-scoped rule picks up future resources automatically, forever. Contrast the sibling suppression module, where the same configuration silences everything.
6 Β· Scope breadth, computed
module "mixed_scopes" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-mixed-scopes"
resource_group_name = module.monitor_rg.name
scopes = [
"/subscriptions/${var.subscription_id}", # subscription
"/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod", # resource_group
var.web_vm_id, # resource
]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0"] }
}
}
output "breadth" {
value = module.mixed_scopes.scope_breadth
}β
scope_breadthclassifies each entry assubscription,resource_grouporresource, derived from the ID's segment count. A plan shows only opaque ID strings; this makes the reach reviewable.
β οΈ Mixing breadths is legal and the widest one wins in practice β a subscription entry already covers the other two, so the narrower entries add nothing.subscription_wide_scopesnames the culprit.
βΉοΈ
scopesis not force-new, so reach can be widened in place. Convenient, and also how a narrow rule quietly becomes a broad one across a few commits.
7 Β· A maintenance window
module "migration_window" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-migration-escalation"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]
add_action_group_ids = [var.migration_team_action_group_id]
schedule = {
effective_from = "2026-08-01T22:00:00"
effective_until = "2026-08-02T06:00:00"
time_zone = "Eastern Standard Time"
}
}
output "window" {
value = {
always = module.migration_window.is_always_in_force # false
open_ended = module.migration_window.has_open_ended_effective_window # false
}
}
β οΈ Both dates use the fullYYYY-MM-DDTHH:MM:SSform, with no timezone suffix β the zone is the separatetime_zoneargument. A date alone is rejected.
π΄
time_zonetakes a WINDOWS zone name such as"Eastern Standard Time", not"America/New_York". Microsoft publishes the values as a reference table rather than an enforceable set, so an IANA name passes plan and misbehaves later β the rule is then in force at the wrong hours.
β Supplying
effective_untilis what makes this a window. Omit it and see example 8.
8 Β· The window that never ends
module "temporary_escalation" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-temporary"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
schedule = {
# A start with no end. This is permanent from 1 August onward.
effective_from = "2026-08-01T00:00:00"
}
}
check "windows_actually_close" {
assert {
condition = module.temporary_escalation.has_open_ended_effective_window == false
error_message = "This rule names a start date but no end date, so it is permanent."
}
}π΄ A configuration that reads as time-bounded and is not. It names a start date β exactly what a planned change looks like β and from that date onward it never expires. No later plan will show anything to remind anyone.
β
has_open_ended_effective_windowis reported separately fromis_always_in_force, because the two are identical in effect after the start date and look completely different in a configuration.
βΉοΈ On this module the consequence is extra notifications. On the sibling suppression module the same shape means alerts silenced indefinitely β which is why that module leads with it.
9 Β· Recurrence, and the asymmetry in it
module "business_hours" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-business-hours"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [var.daytime_team_action_group_id]
schedule = {
time_zone = "Pacific Standard Time"
recurrence = {
# daily REQUIRES both times.
daily = [{ start_time = "08:00:00", end_time = "18:00:00" }]
# weekly and monthly make both OPTIONAL, because the days already pin them.
weekly = [{ days_of_week = ["Saturday", "Sunday"] }]
monthly = [{ days_of_month = [1, 15] }]
}
}
}
output "recurrence" {
value = {
windows = module.business_hours.recurrence_window_count # 3
multi_kind = module.business_hours.uses_multiple_recurrence_kinds # true
}
}
β οΈ dailyrequires bothstart_timeandend_time;weeklyandmonthlydo not. That asymmetry is the provider's and is consistent once seen β a daily window has no other way to say when it applies, whereas a weekly or monthly one is already pinned by its days. This module's types mirror it exactly rather than smoothing it into a uniform shape that would then be wrong.
π΄ All three kinds are REPEATING and combine as a UNION, not an intersection. A daily window plus a weekly one does not mean "daily, but only at weekends" β it means "during either". So the total covered time is larger than either alone.
uses_multiple_recurrence_kindsreports the combination.
βΉοΈ Recurrence times are
HH:MM:SSβ a different format fromeffective_from's full timestamp. Both are validated separately, each naming its own format.
10 Β· Staging a rule before enabling it
module "staged_rule" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-new-escalation"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0"] }
}
# Create it, review the breadth outputs, then flip this in a second apply.
enabled = false
description = "Escalate Sev0 to on-call. Staged 2026-07-31, pending review of scope breadth."
}β
enableddefaults totruein both the provider and this module, mirrored explicitly so the intent is visible in a configuration rather than implied by absence. So a rule is live on the apply that creates it unless you say otherwise.
βΉοΈ Two-step introduction is the safer pattern: create with
enabled = false, readscope_breadthandmatches_every_alert_in_scope, then enable deliberately.
π¬ Write the
description. An alert processing rule changes what happens to other people's alerts, and the next person wondering why an alert behaved oddly will find this rule before they find whoever created it.
11 Β· Choosing between this module and its sibling
# ADD notifications -- this module. A mistake announces itself: someone gets paged who should not have been.
module "escalate" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-escalate-sev0"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0"] }
}
}
# REMOVE notifications -- the sibling. A mistake is SILENT: nobody is paged who should have been.
module "silence_during_migration" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-suppression.git?ref=v1.0.0"
name = "apr-migration-quiet"
resource_group_name = module.monitor_rg.name
scopes = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]
conditions = {
monitor_service = { operator = "Equals", values = ["Platform"] }
}
schedule = {
effective_from = "2026-08-01T22:00:00"
effective_until = "2026-08-02T06:00:00"
}
}β The two resources are schema-identical apart from one argument β this module's
add_action_group_ids. Verified by diffing the schema dumps, which produced exactly one line of difference. Everything you learn about conditions or scheduling here transfers unchanged.
π΄ That identical shape is also a hazard. Copying a configuration between them is easy, and copying this module's broad
scopesinto a suppression rule turns "page the on-call team for everything" into "silence everything".
β οΈ Note the suppression rule above is narrowly scoped and time-bounded. That is the shape a suppression rule should have, and it is the opposite of what an action-group rule usually wants.
12 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "monitor_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-monitor-prod"
location = "eastus2"
tags = {
environment = "prod"
workload = "observability"
}
}
module "oncall_action_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-action-group.git?ref=v1.0.0"
name = "ag-oncall-prod"
resource_group_name = module.monitor_rg.name
short_name = "oncall"
}
module "escalate_high_severity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"
name = "apr-oncall-sev01"
resource_group_name = module.monitor_rg.name
# Subscription-wide on purpose: the point is to cover future resources too.
scopes = ["/subscriptions/${var.subscription_id}"]
add_action_group_ids = [module.oncall_action_group.id]
conditions = {
severity = { operator = "Equals", values = ["Sev0", "Sev1"] }
monitor_condition = { operator = "Equals", values = ["Fired"] }
}
# Out of hours only -- the daytime team handles the rest.
schedule = {
time_zone = "Eastern Standard Time"
recurrence = {
daily = [{ start_time = "18:00:00", end_time = "08:00:00" }]
weekly = [{ days_of_week = ["Saturday", "Sunday"] }]
}
}
description = "Page on-call for Sev0/Sev1 firing alerts, out of hours and at weekends."
tags = {
environment = "prod"
}
}
# The composition carries what no single argument check can.
output "alert_routing_posture" {
value = {
# Reach, in reviewable form.
breadth = module.escalate_high_severity.scope_breadth
sub_wide = module.escalate_high_severity.has_subscription_wide_scope
unconditional = module.escalate_high_severity.matches_every_alert_in_scope
# What it matches, and how the matching composes.
fields = module.escalate_high_severity.active_condition_fields
anded = module.escalate_high_severity.conditions_are_combined_with_and
# When it applies.
always = module.escalate_high_severity.is_always_in_force
open_ended = module.escalate_high_severity.has_open_ended_effective_window
union = module.escalate_high_severity.uses_multiple_recurrence_kinds
# Whose alerts this touches.
other_peoples = module.escalate_high_severity.this_rule_changes_other_peoples_alerts
}
}
check "alert_processing_rule_is_understood" {
assert {
condition = module.escalate_high_severity.has_open_ended_effective_window == false
error_message = "The rule names a start date with no end, so it is permanent."
}
assert {
condition = module.escalate_high_severity.matches_every_alert_in_scope == false
error_message = "The rule has no conditions, so it applies to every alert in scope."
}
}π‘ Order comes from references alone β the resource group's
nameand the action group'sid. Nothing here needsdepends_on.
β The two
checkassertions are the point. Each asserts something the module reports but deliberately does not refuse: an unbounded effective window and an unconditional match are both legal and sometimes intended. The module makes them visible; the composition decides.
π΄
terraform destroyremoves the rule and nothing else. The alerts it was acting on continue exactly as their own rules define β which is the clearest illustration that this resource owns none of them.
Required (4) β name, resource_group_name, scopes, add_action_group_ids.
Matching (1) β conditions, a map keyed by field name. Empty means every alert in scope.
Timing (1) β schedule. Omitting it means always in force.
Metadata + tail (4) β description, enabled (default true), tags, timeouts.
Full input schemas
| Name | Type | Default | Required |
|---|---|---|---|
name |
string |
β | β |
resource_group_name |
string |
β | β |
scopes |
list(string) |
β | β |
add_action_group_ids |
list(string) |
β | β |
conditions |
map(object({ operator, values })) |
{} |
β |
schedule |
object β see below |
null |
β |
description |
string |
null |
β |
enabled |
bool |
true |
β |
tags |
map(string) |
{} |
β |
timeouts |
object({ create, read, update, delete }) |
null |
β |
# Eleven legal keys. CASE-SENSITIVE -- an unknown key is rejected, because it would
# otherwise be routed nowhere and silently dropped.
conditions = {
alert_context = {}, alert_rule_id = {}, alert_rule_name = {}, description = {},
monitor_condition = {}, monitor_service = {}, severity = {}, signal_type = {},
target_resource = {}, target_resource_group = {}, target_resource_type = {}
# each value: { operator = string, values = list(string) }
}
# Operators by field:
# Equals | NotEquals | Contains | DoesNotContain
# alert_context, alert_rule_id, alert_rule_name, description,
# target_resource, target_resource_group, target_resource_type
# Equals | NotEquals ONLY
# monitor_condition, monitor_service, severity, signal_type
schedule = {
effective_from = optional(string) # "YYYY-MM-DDTHH:MM:SS"
effective_until = optional(string) # omit for a window that never closes
time_zone = optional(string) # WINDOWS zone name; provider default "UTC"
recurrence = optional(object({
# All three REPEATING and independently optional. They UNION.
daily = optional(list(object({ start_time = string, end_time = string })), [])
weekly = optional(list(object({ days_of_week = list(string), start_time = optional(string), end_time = optional(string) })), [])
monthly = optional(list(object({ days_of_month = list(number), start_time = optional(string), end_time = optional(string) })), [])
}))
}25 validations, all proven to fire by line number β and every one isolated by its own fixture, with no
overlaps. Notably: the case-sensitive condition-key check, the two operator classes as separate checks, four
closed value sets including the nineteen space-containing monitor_service names, both time formats, and the
action group ID anchored to Microsoft.Insights/actionGroups.
| Output | Description | Notes |
|---|---|---|
id / name / resource_group_name / enabled |
Identity | id first |
scopes / scope_breadth |
Reach, classified | Read this |
subscription_wide_scopes / resource_group_scopes / resource_scopes |
Per breadth | |
has_subscription_wide_scope |
Reach flag | check blocks |
add_action_group_ids / action_group_names |
The action | Names parsed for readability |
active_condition_fields / condition_count |
What it matches | |
matches_every_alert_in_scope |
No conditions | check blocks |
conditions_are_combined_with_and |
Constant true |
AND, not OR |
eleven_condition_fields_are_rendered_from_one_map |
Constant true |
This module's shape |
condition_operators_are_not_uniform_across_fields |
Constant true |
Two operator classes |
four_condition_fields_have_closed_value_sets |
Constant true |
|
is_always_in_force / effective_from / effective_until / time_zone |
Timing | |
has_open_ended_effective_window |
A start with no end | check blocks |
schedule_times_use_windows_time_zone_names |
Constant true |
|
recurrence_window_count / has_recurrence / uses_multiple_recurrence_kinds |
Recurrence | Union, not intersection |
daily_windows_require_both_times_while_weekly_and_monthly_do_not |
Constant true |
The provider's asymmetry |
two_time_formats_are_in_play |
Constant true |
|
this_rule_changes_other_peoples_alerts |
Constant true |
Read this one |
the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action |
Constant true |
Choosing a sibling |
accepts_no_credential |
Constant true |
Eleven near-identical blocks become one map. The provider defines eleven single-instance blocks inside
condition, each with the same operator plus values pair. Writing eleven of those by hand is how a typo
hides, so this module accepts one map keyed by field name and renders them all. The key check is
case-sensitive and does not trim, deliberately: entries are routed into the provider block of the same
name, so "Severity" would match nothing and be silently dropped β the condition would simply not exist, with
no error from Terraform and none from Azure. Rejecting the key at plan time is the only place that mistake can
be caught, and it is the same reasoning that made a day-key check case-sensitive elsewhere in this suite.
The operators are not uniform, so they are validated per field. Seven fields hold free-form text or
Resource IDs and accept the substring operators; four have closed value sets and accept only Equals and
NotEquals. Applying the wider set everywhere would have let Contains through on severity, which the
provider documents as invalid β so the split is enforced as two checks, one per operator class. Four of the
eleven also have closed value sets, and monitor_service's nineteen names are the ones that catch people:
several contain spaces, so "ActivityLog Administrative" is one value rather than two.
Two levels of matching behave differently. Conditions are ANDed with each other; values within a single field are ORed. Getting that backwards produces a rule that matches far less than intended, and on this module the symptom is visible β someone does not get paged who expected to be. On the sibling suppression module the same error is invisible, which is why both modules state the asymmetry explicitly.
An empty condition map is expressed by omitting the block. The provider documents an at-least-one-of rule
across the eleven fields; this module satisfies it by not emitting condition at all when the map is empty,
rather than by violating it. That makes "every alert in scope" a legal, reachable state β appropriate for an
action-group rule, and reported through matches_every_alert_in_scope so it is a decision rather than an
accident.
Scope is not a snapshot. scopes is the rule's reach for as long as the rule exists, so a
subscription-scoped entry covers resources that do not exist yet, created by teams who will never see this
configuration. The module classifies each entry's breadth by segment count and emits the classification,
because a plan shows only opaque ID strings. And scopes is not force-new, so reach can be widened in
place β convenient, and also how a narrow rule quietly becomes a broad one over a few commits.
Timing has three traps, all named. A schedule with a start and no end is permanent from that date while
reading as time-bounded, so it gets its own flag separate from having no schedule at all. The three recurrence
kinds are repeating and combine as a union, so adding a weekly window to a daily one increases the
covered time rather than intersecting it. And two different time formats coexist β full timestamps for the
effective window, bare clock times for recurrence β each validated with a message naming its own format.
time_zone takes Windows zone names published as a reference table rather than an enforceable set, so an IANA
name passes plan and puts the rule in force at the wrong hours.
This resource owns none of the alerts it changes. They come from other alert rules, which record nothing
about it, and terraform destroy here leaves them behaving exactly as their own definitions say. That is why
the module's headline constant is about reach rather than about any argument.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| There is no empty call | Four arguments are required | β |
conditions |
Empty β every alert in scope, and reported as such | add conditions to narrow |
| A wrong-case condition key | Rejected, because it would otherwise be silently dropped | β |
| Operators | Enforced per field, two classes | β |
| The four closed value sets | Enforced exactly as published | β |
| Free-form condition values | Not constrained β this module invents no rules about alert names | β |
scopes breadth |
Reported per entry, never restricted | β |
add_action_group_ids |
Anchored to Microsoft.Insights/actionGroups |
β |
enabled |
true β the provider's default, mirrored explicitly |
set false to stage |
schedule |
Absent β always in force, and reported | add a window |
| A start with no end | Reported, not rejected | β |
| Multiple recurrence kinds | Reported, because they union rather than intersect | β |
time_zone |
Not validated against a set β Microsoft publishes a table, not an enum | β |
β Nothing here needed a security flip, because nothing in this resource is a security control in the usual sense β it routes notifications. What the module contributes is making reach legible: which scopes, how broad, how many conditions, and for how long.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module by tag, never by branch:
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"Authored and verified plan-only. No cloud apply happens during authoring or review; a human applies from CI.
| Gate | What it proves | What it does not |
|---|---|---|
terraform init -backend=false |
The provider constraint resolves | Nothing about Azure |
terraform validate |
HCL parses; types line up; validations are declared | It does not run root-module variable validations from a calling configuration |
terraform fmt -check |
Canonical formatting | Nothing semantic |
terraform console with .tfvars files |
The real offline harness. All 25 validations proven to fire, by line number | Nothing needing an Azure API call |
terraform plan |
Provider-level validation | Requires credentials; not run here |
| Apply | Whether the scopes and action groups exist, and whether anything in scope ever fires | β |
All 25 validations were proven individually by line number, unioned across twenty-five deliberately bad
fixtures and diffed against the anchored count of validation blocks.
Every fixture isolated exactly one validation β there were no overlaps at all, which is unusual in this library and worth recording as the cleanest proof run so far. It happened because the checks partition naturally: emptiness, shape, key membership, two operator classes and four value sets are all disjoint conditions rather than nested ones.
Every derived local was printed across ten good fixtures β minimal, subscription-scoped, mixed breadths, three
conditions, the space-containing monitor_service values, a closed window, an open-ended one, single and
multiple recurrence kinds, and all eleven condition fields at once. That confirmed active_condition_fields
reaches 11, that uses_multiple_recurrence_kinds and recurrence_window_count track correctly at 3, and that
scope_breadth classifies a subscription, a resource group and a resource distinctly from one mixed list.
Outputs:
accepts_no_credential = true
action_group_names = [
"ag-oncall-prod",
]
active_condition_fields = [
"monitor_condition",
"severity",
]
add_action_group_ids = [
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-monitor-prod/providers/Microsoft.Insights/actionGroups/ag-oncall-prod",
]
condition_count = 2
condition_operators_are_not_uniform_across_fields = true
conditions_are_combined_with_and = true
daily_windows_require_both_times_while_weekly_and_monthly_do_not = true
effective_from = null
effective_until = null
eleven_condition_fields_are_rendered_from_one_map = true
enabled = true
four_condition_fields_have_closed_value_sets = true
has_open_ended_effective_window = false
has_recurrence = true
has_subscription_wide_scope = true
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-monitor-prod/providers/Microsoft.AlertsManagement/actionRules/apr-oncall-sev01"
is_always_in_force = false
matches_every_alert_in_scope = false
name = "apr-oncall-sev01"
recurrence_window_count = 2
resource_group_name = "rg-monitor-prod"
resource_group_scopes = []
resource_scopes = []
scope_breadth = {
"/subscriptions/00000000-0000-0000-0000-000000000000" = "subscription"
}
schedule_times_use_windows_time_zone_names = true
scopes = [
"/subscriptions/00000000-0000-0000-0000-000000000000",
]
subscription_wide_scopes = [
"/subscriptions/00000000-0000-0000-0000-000000000000",
]
the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action = true
this_rule_changes_other_peoples_alerts = true
time_zone = "Eastern Standard Time"
two_time_formats_are_in_play = true
uses_multiple_recurrence_kinds = true
| Symptom | Cause | Fix |
|---|---|---|
Every conditions map key must be one of the eleven... |
A wrong-case or invented key, e.g. "Severity". |
Exactly lowercase, exactly as the provider names them. |
| A condition seems to have no effect | On another tool, a wrong key is silently dropped. | This module rejects it at plan time β that is the point of the check. |
operator must be exactly "Equals" or "NotEquals" |
Contains used on one of the four closed-set fields. |
Those have no substring to match. |
severity value rejected |
"0", "Critical" or "Sev5". |
Sev0 through Sev4 only. |
monitor_service value rejected |
A value split at a space, or wrong case. | "ActivityLog Administrative" is one value; "VM Insights - Health" keeps its spaces. |
| The rule matches fewer alerts than expected | Conditions are ANDed, not ORed. | Remove a condition, or widen a values list. |
| The rule matches everything | conditions = {} or omitted. |
Check matches_every_alert_in_scope. |
effective_from must be a timestamp... |
A date alone, or a clock time. | YYYY-MM-DDTHH:MM:SS, no timezone suffix. |
| A recurrence time rejected | "22:00" instead of "22:00:00". |
Recurrence uses HH:MM:SS. |
| The rule fires at the wrong hours | time_zone given an IANA name. |
Windows zone names only. Not validated here. |
A daily block rejected for a missing time |
daily requires both times. |
weekly and monthly do not β the days pin them. |
| More time covered than expected | Multiple recurrence kinds union. | Check uses_multiple_recurrence_kinds. |
add_action_group_ids rejected |
An AlertsManagement path, or an action group name. |
Action groups live under Microsoft.Insights/actionGroups. |
| The rule affects alerts in another resource group | scopes, not resource_group_name, decides reach. |
The resource group only holds the rule record. |
| A temporary rule never stopped applying | effective_from with no effective_until. |
See has_open_ended_effective_window. |
- Provider resource:
azurerm_monitor_alert_processing_rule_action_group - Provider resource:
azurerm_monitor_alert_processing_rule_suppressionβ schema-identical apart from one argument - Platform: Alert processing rules
- Platform: Action groups
- Siblings:
terraform-azurerm-monitor-alert-processing-rule-suppression,terraform-azurerm-monitor-action-group,terraform-azurerm-resource-group - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."