Emails a saved Cost Analysis view on a schedule — and expires, because
end_dateis required and there is no "never" (azurerm_cost_management_scheduled_action). Targetshashicorp/azurerm ~> 4.0.
- 📧 A recurring email of a saved Cost Analysis view — its chart, and optionally a CSV link.
- 🔴 Every schedule expires.
end_dateis required, there is no "never", and once it passes the emails stop with no warning and no plan diff. Azure caps it at one year. - 🔴 Delivery depends on the creator's access. Azure checks that whoever created the schedule still holds
Readerbefore sending each email. Lose it and the emails stop silently. - ✅ Which makes Terraform an advantage: the identity running the apply becomes the creator, so applying from CI with a service principal keeps the schedule alive.
- 🔴 The view decides what the email contains. This resource owns the schedule and the recipients only — and
view_idis force-new. - 🔒 Recipients need no Azure access. Azure documents this explicitly, so
email_addressessends cost data outside Azure's access-control boundary. ⚠️ frequencycarries documented conditional requirements, which this module enforces:Weeklyneedsdays_of_week;Monthlyneeds eitherday_of_monthorweeks_of_monthwithdays_of_week.⚠️ hour_of_dayis UTC with no time-zone field, so a "morning report" is only morning for part of the year.⚠️ The ARM namespace is shared withazurerm_cost_anomaly_alert— names must be unique across both.
💡 Why it matters: The two things most likely to break this are a date you set once and an access grant you never think about again. Neither produces an error, a diff, or a symptom other than an email that stops arriving.
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
flowchart TB
creator["THE IDENTITY THAT RUNS THE APPLY becomes the schedule's CREATOR. Azure checks that it still holds Reader before sending each email, so if it loses that the reports STOP SILENTLY - record healthy, plan clean, nothing said. Apply from CI with a service principal."]
view["terraform-azurerm-subscription-cost-management-view. IT decides which costs are reported and how they are grouped - this module only decides when and to whom. Force-new, and Azure cannot subscribe to management-group-scoped views or to table views."]
sched["terraform-azurerm-cost-management-scheduled-action"]
mailbox["A MONITORED SHARED MAILBOX. Azure documents that recipients need NO portal or Cost Management access to read the email, its chart or its CSV - so this list sends cost data outside Azure's access-control boundary."]
alert["terraform-azurerm-cost-anomaly-alert: the same ARM type, so names must be unique across BOTH. It notifies on unexpected change; this one reports on a schedule."]
exports["terraform-azurerm-subscription-cost-management-export and billing-account-cost-management-export: use an export when something must PROCESS the numbers, and this when a human must READ them."]
expiry["AND EVERY SCHEDULE EXPIRES. end_date is REQUIRED with no never option, Azure caps it at one year, and once it passes the emails simply stop - no warning, no plan diff. It updates in place, so the fix is cheap; remembering is the hard part."]
creator -->|"creates, and must keep Reader"| sched
view -->|"view_id, force-new"| sched
sched -->|"email_addresses, a disclosure boundary"| mailbox
sched -->|"shares one namespace with"| alert
exports -->|"the data-processing alternative"| sched
sched -->|"but see"| expiry
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class sched mine;
class creator,expiry keystone;
class view,mailbox,alert,exports sib;
flowchart TB
freq["frequency is Daily, Weekly or Monthly - and it carries DOCUMENTED conditional requirements, so they are ENFORCED: Weekly needs days_of_week, and Monthly needs either day_of_month OR weeks_of_month together with days_of_week."]
placed["Both checks are PLACED ON frequency, one-directionally, because a validation may only reference its own variable and two variables checking each other is rejected as a cycle. So each message says which variable carries the check and why it names a field you were not editing."]
ignored["ignored_schedule_fields: derived, because the provider documents these as applicable-when rather than forbidden-unless. It names anything supplied that CANNOT take effect - rejecting it would refuse a configuration Azure accepts. Assert empty."]
dates["start_date and end_date, both REQUIRED, both RFC3339 UTC. end_date greater than start_date IS enforced. The one-year cap is NOT - the bound is a year from today or from start_date, whichever is LATER, so a check based on start_date alone would reject legal input."]
utc["hour_of_day is UTC with NO time-zone field, so the local send time drifts with daylight saving. And day_of_month accepts 29 to 31, which do not occur in every month."]
summary["schedule_summary: derived, because the schedule is spread across five interacting fields. A reviewer asking when does this actually send should not reassemble them mentally - and it always states UTC, because that is the mistake."]
this["azurerm_cost_management_scheduled_action.this"]
flags["stops_sending_after_end_date, delivery_depends_on_the_creators_access, recipients_need_no_azure_access, view_must_exist_and_is_not_created_here - four constants, because each one fails silently."]
freq -->|"required"| this
freq -->|"checks live here"| placed
placed -->|"and the rest are reported"| ignored
dates -->|"required, one check enforced and one documented"| this
utc -->|"schedule detail"| this
this -->|"reassembled into"| summary
this -->|"emits"| flags
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this mine;
class freq mine;
class flags,placed keystone;
class ignored,dates,utc,summary sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_cost_management_scheduled_action.this |
1 | The keystone. A schedule record; it does no work. |
timeouts block |
0..1 | All four operations exist — and none affects delivery. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Azure resource provider | Microsoft.CostManagement/scheduledActions — API 2023-08-01 |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite — confirmed against the live provider schema and its documentation:
- 🔴
end_dateis required and delivery stops once it passes (example 2). - 🔴 Delivery depends on the creating identity's continuing access (example 3).
- 🔴
view_idis force-new and decides the content — and management-group-scoped views and table views cannot be subscribed to (example 4). - 🔴 The ARM type is shared with
azurerm_cost_anomaly_alert(example 9). - 🔒 Recipients need no Azure access (example 8).
⚠️ frequencycarries documented conditional requirements (examples 5, 6).⚠️ hour_of_dayis UTC; there is no time-zone field (example 7).⚠️ day_of_monthaccepts 29–31, which do not occur in every month (example 7).⚠️ email_address_senderis the unsubscribe contact, not the From address (example 8).⚠️ email_addressesis alisthere and aseton the sibling;messageallows 250 here and 100 there (example 9).⚠️ NoAnnuallyfrequency, unlike this library's cost-management export modules (example 5).⚠️ Force-new:name,view_id(example 10).⚠️ Notagsattribute, so this module carries no tags variable.lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy(example 11).
| Operation | Role | Scope |
|---|---|---|
| Create or update the scheduled action | Cost Management Contributor (or Microsoft.CostManagement/scheduledActions/write) |
the subscription |
| Read the scheduled action | Cost Management Reader or Reader | the subscription |
| Read the referenced view | Cost Management Reader or Reader | the view's scope |
| 🔴 Keep the reports arriving | Reader — or Microsoft.CostManagement/scheduledActions/read — held continuously by the identity that created the schedule |
the subscription |
| Read the report email, chart and CSV | none | — |
🔴 The fourth row is the one nobody audits. It is not a permission needed to apply, but one that must keep existing afterwards, held by the creating identity (example 3).
🔒 The last row is deliberate, not an omission (example 8).
- 🔴 A Cost Management view that supports subscribing — not a management-group-scoped view, and not a table view (example 4).
- 🔴 An identity that will retain
Reader— ideally a service principal (example 3). - 🔴 A plan for renewing
end_date(example 2). - ✅ A monitored shared mailbox for
email_addressesandemail_address_sender(example 8). ⚠️ For indirect Enterprise Agreements and Microsoft Customer Agreements, the view charges policy must be enabled, or the emails are suppressed regardless of this configuration (example 12).⚠️ A name not already used by a cost anomaly alert in the same subscription (example 9).
terraform-azurerm-cost-management-scheduled-action/
├── providers.tf # required_version + the pinned azurerm provider. No provider block.
├── variables.tf # name, display_name, view_id, email_addresses, email_address_sender,
# # email_subject, message, frequency, start_date, end_date,
# # day_of_month, days_of_week, weeks_of_month, hour_of_day, timeouts
├── main.tf # the keystone `this`
├── outputs.tf # id first, then the view, recipients and schedule, then the derived and constant flags
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "monthly_report" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cost-management-scheduled-action.git?ref=v1.0.0"
name = "report-prod-monthly"
display_name = "Production monthly cost report"
# 🔴 The view decides the content, and this is force-new (example 4).
view_id = var.cost_view_id
# 🔒 A monitored shared mailbox: recipients need no Azure access (example 8).
email_addresses = ["cloud-cost-alerts@contoso.example"]
email_address_sender = "cloud-cost-alerts@contoso.example"
email_subject = "Prod monthly cost report" # ≤ 70 characters
frequency = "Monthly"
day_of_month = 1
hour_of_day = 8 # ⚠️ UTC, not local (example 7)
start_date = "2026-08-01T00:00:00Z"
end_date = "2027-07-31T00:00:00Z" # 🔴 and then it stops (example 2)
}🔴 Apply this from CI, not from your own session. The identity that applies becomes the creator, and the reports stop the day it loses access (example 3).
ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source module |
|---|---|---|
name |
string |
caller — force-new; unique across both scheduledActions types |
display_name |
string |
caller — updates in place |
view_id |
string |
terraform-azurerm-subscription-cost-management-view output id |
email_addresses |
list(string) |
caller — required, ≥1; 🔒 a disclosure boundary |
email_address_sender |
string |
caller — required; the unsubscribe contact |
email_subject |
string |
caller — required, ≤70 characters |
message |
string |
caller — ≤250 characters |
frequency |
string |
caller — required; Daily / Weekly / Monthly |
start_date / end_date |
string |
caller — both required, RFC3339 UTC |
day_of_month / days_of_week / weeks_of_month / hour_of_day |
various | caller — applicability depends on frequency |
timeouts |
object(...) |
caller — all four operations exist |
ℹ️ No
tags. The provider exposes none on this resource.
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The Resource ID — the same type as an anomaly alert. | review |
name / display_name |
Identity. name is force-new. |
review |
view_id |
🔴 Force-new. Decides the report's content. | report review |
email_addresses |
🔒 Deliberately un-redacted. | privacy review |
email_recipient_count |
And a list-versus-set note. |
review |
email_address_sender |
The unsubscribe contact. | review |
email_subject / message |
Content. | review |
frequency / start_date / end_date |
The schedule. | review |
hour_of_day / day_of_month / days_of_week / weeks_of_month |
Schedule detail. | review |
schedule_summary |
✅ Derived — the whole schedule in one string. | review |
ignored_schedule_fields |
review | |
stops_sending_after_end_date |
🔴 Always true. |
runbook |
delivery_depends_on_the_creators_access |
🔴 Always true. |
runbook / access review |
recipients_need_no_azure_access |
🔒 Always true. |
privacy review |
view_must_exist_and_is_not_created_here |
Always true. |
prerequisite review |
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 "cost_view_id" {
description = "id of an existing cost view that these examples reference but do not create."
type = string
}
variable "other_view_id" {
description = "id of an existing other view that these examples reference but do not create."
type = string
}1 · The smallest working schedule
module "daily_report" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cost-management-scheduled-action.git?ref=v1.0.0"
name = "report-prod-daily"
display_name = "Production daily cost report"
view_id = var.cost_view_id
email_addresses = ["cloud-cost-alerts@contoso.example"]
email_address_sender = "cloud-cost-alerts@contoso.example"
email_subject = "Prod daily cost"
frequency = "Daily"
start_date = "2026-08-01T00:00:00Z"
end_date = "2027-07-31T00:00:00Z"
}ℹ️
Dailyis the only frequency that needs no schedule detail.WeeklyandMonthlyboth carry documented requirements (examples 5, 6).
🔴 Eight required arguments, and two of them are dates you will forget.
end_datehas no "never" option, so this schedule stops on 31 July 2027 whether anybody remembers or not (example 2).
⚠️ hour_of_dayis omitted here, so Azure picks a time. That is fine for a daily report and less fine when somebody expects it before a morning meeting (example 7).
ℹ️ There is no
tagsvariable, because the provider exposes notagsattribute here — verified against the schema.
2 · 🔴 Every schedule expires, silently
end_date = "2027-07-31T00:00:00Z"
1 Aug 2027 the emails stop
-> the resource is healthy
-> terraform plan is clean
-> Azure sends no warning
-> the first symptom is somebody asking where the report went
🔴
end_dateis required. There is no "never expires". Azure additionally caps it at one year from today or fromstart_date, whichever is later — so you cannot simply set it far enough out to forget about.
✅ The remedy is cheap, because
end_dateupdates in place. Extending it is not a replacement:
output "diarise_this" {
value = module.monthly_report.stops_sending_after_end_date # always true
}✅ Better still, drive it from a value your pipeline refreshes, so renewal is automatic rather than remembered:
end_date = var.cost_report_end_date # bumped by CI on each run
⚠️ The module enforcesend_date > start_dateand deliberately does not enforce the one-year cap. The cap is relative to today orstart_date, whichever is later, so with a paststart_datethe real limit is later than any check based onstart_datealone could allow — enforcing it would reject legal input. It is documented instead.
💡 Treat expiry as a scheduled outage of your cost reporting, because that is what it is. It is the single most likely reason this resource stops doing its job.
3 · 🔴 The report stops when its creator loses access
Azure checks the CREATOR's permissions before sending each email:
Reader — or Microsoft.CostManagement/scheduledActions/read — on the subscription
Lose it, and delivery ends. Record healthy. Plan clean. Nothing said.
🔴 This is the second silent failure mode, and it is independent of the first. A schedule can be well within its
end_dateand still deliver nothing, because the identity that created it no longer has access.
✅ Terraform makes the right answer easy: the identity that applies becomes the creator. Apply from CI with a service principal and the schedule outlives everyone; Microsoft recommends exactly this where elevated access is not permanently assigned to individuals.
output "who_created_this_matters" {
value = module.monthly_report.delivery_depends_on_the_creators_access # always true
}
⚠️ A force-new change re-parents the creator. Editingnameorview_iddestroys and recreates the schedule under whatever identity applies at that moment — so a "small fix" from somebody's laptop quietly ties the subscription's cost reporting to that person (example 10).
💡 Keep the creating principal's
Readerassignment in the same configuration (example 12), so the grant that keeps the reports alive is visible next to the thing depending on it.
4 · 🔴 The view decides the content — and two kinds do not work
view_id = var.cost_view_id # ✅ a subscription-scoped Cost Management view🔴 This resource owns the schedule and the recipients. The view owns everything else — which costs are included, how they are grouped, what the chart shows. So a scheduled action is only as useful as the view behind it, and reviewing one means going to look at the other.
🔴
view_idis force-new, so changing which report is sent replaces the schedule — and therefore re-parents its creator (example 3).
⚠️ Azure cannot subscribe to two view kinds, and neither is detectable from a Resource ID:
management-group-scoped views -> Subscribe is unavailable
table views -> Subscribe is unavailable
output "check_the_view" {
value = module.monthly_report.view_must_exist_and_is_not_created_here # always true
}✅ The validation anchors the trailing segment without pinning the scope prefix, because a subscription view and a resource-group view are both legitimate:
view_id must be a Cost Management view Resource ID ending in
/providers/Microsoft.CostManagement/views/<view>. ⚠️ Passing the scheduled action's own ID, or
a scope without the views segment, is the usual mistake.
ℹ️ Built-in views work too —
DailyCostsand friends — and are referenced by their own IDs rather than created.
5 · ⚠️ Frequency, and the requirements it drags in
frequency = "Daily" # ✅ needs nothing else
frequency = "Weekly"
days_of_week = ["Monday"] # 🔴 required
frequency = "Monthly"
day_of_month = 1 # 🔴 either this…
frequency = "Monthly"
weeks_of_month = ["First"] # 🔴 …or this pair
days_of_week = ["Monday"]🔴 The provider documents these as requirements, so this module enforces them — per this library's philosophy of enforcing what the provider publishes and no more:
frequency = "Monthly" requires either day_of_month, or weeks_of_month together with
days_of_week — the provider documents this either/or, and weeks_of_month alone is not enough.
⚠️ weeks_of_monthalone is not a valid monthly schedule. It needsdays_of_weekto say which day of that week — "the first week" is not a date.
⚠️ There is noAnnually, unlike this library's cost-management export modules, which do offer it. Same family, different value sets:
frequency must be exactly one of: Daily, Weekly, Monthly. The values are case-sensitive.
ℹ️ Note there is no Annually option here, unlike the cost-management *export* resources.
✅
schedule_summaryreassembles all of this into one readable line, because five interacting fields is more than a reviewer should hold in their head (example 7).
6 · Where the conditional checks live, and why
Both frequency checks are placed ON `frequency`, referencing the other variables
one-directionally — because a validation condition must reference its own variable, and two
variables validating each other is rejected as a cycle.
⚠️ So the error names a field you were not editing, and the message says so rather than leaving you to wonder:
frequency = "Weekly" requires days_of_week to be set — the provider documents this. ⚠️ This
check is placed on `frequency` because a validation may only reference its own variable, so it
names days_of_week even when you were editing frequency.
✅ The applicability statements are reported instead of enforced. The provider says
day_of_monthis applicable whenfrequencyisMonthly— not that it is forbidden otherwise — so supplying it on a daily schedule is accepted by Azure and simply ignored. Rejecting it would refuse a configuration Azure accepts:
output "dead_settings" {
value = module.daily_report.ignored_schedule_fields # ⚠️ assert this is empty
}
# e.g. ["day_of_month"] when frequency = "Daily" and day_of_month = 1🔴 A non-empty list means part of the schedule you wrote is doing nothing. That is not an error — it is worse, because nothing complains.
✅ Duplicates are rejected, in
days_of_weekandweeks_of_month. The provider takes lists rather than sets, so a repeated day type-checks and means nothing to Azure — exactly the sort of no-signal mistake worth catching at plan time.
7 · ⚠️ UTC, and the days that do not exist
hour_of_day = 8 # ⚠️ 08:00 UTC — NOT 08:00 local, and it does not follow daylight saving
day_of_month = 31 # ⚠️ does not occur in February, April, June, September or November
day_of_month = 28 # ✅ occurs in every month
⚠️ There is no time-zone field. A report set for8arrives at 08:00 UTC year-round, so in any locale that observes daylight saving it is a "morning report" for only part of the year — and an hour earlier or later than expected for the rest.
⚠️ day_of_monthaccepts 1–31 because Azure publishes that range, but 29, 30 and 31 do not occur in every month and Azure does not document what it does then. The module enforces the published range and says so rather than guessing:
day_of_month must be a whole number between 1 and 31. ⚠️ Remember 29, 30 and 31 do not occur in
every month, and Azure does not document what it does in a short month — prefer a day of 28 or
lower for a genuinely monthly report.
✅
schedule_summarystates UTC explicitly, because that is the mistake:
output "when" {
value = module.monthly_report.schedule_summary # e.g. "Monthly day-1 at 08:00 UTC"
}💡 Convert from local time deliberately, and write the local intent in
messageordisplay_name— otherwise the next person to read the configuration has to work out which one the8was.
8 · 🔒 Recipients, and the field that is not a sender
email_addresses = ["cloud-cost-alerts@contoso.example"] # who receives the report
email_address_sender = "cloud-cost-alerts@contoso.example" # ⚠️ the UNSUBSCRIBE contact
⚠️ email_address_senderis not the From address, despite the name. Mail arrives frommicrosoft-noreply@microsoft.comregardless; this field is who handles unsubscribe traffic. It is required here, while the sibling's equivalent (notification_email) is optional.
🔒 Recipients need no Azure access at all. Azure documents that they require no portal or Cost Management access to view the email, its chart or its linked CSV — so this list sends cost data outside Azure's access-control boundary:
output "disclosure_boundary" {
value = module.monthly_report.recipients_need_no_azure_access # always true
}✅ The addresses are emitted un-redacted, deliberately — not credentials, published to by design, and the field most likely to contain a departed employee's address, which is exactly what a review should catch.
⚠️ email_addressesis alisthere and aseton the sibling, so reordering entries produces a plan diff that means nothing to Azure. Keep it sorted for quiet plans.
🔒 Where recipients are individuals these are personal data. Retention and lawful basis belong with whoever owns data protection; this module surfaces the list and does not answer those questions.
9 · ⚠️ Same family, different rules
cost_management_scheduled_action |
cost_anomaly_alert |
|
|---|---|---|
| ARM type | scheduledActions |
scheduledActions — the same |
| Recipients | list(string) |
set(string) |
message cap |
250 | 250 |
| Unsubscribe contact | email_address_sender, required |
notification_email, optional |
name character rule |
none documented | lowercase, digits, hyphens |
| Frequency | Daily / Weekly / Monthly |
none — detection is daily |
| Expiry | end_date required |
none |
🔴 One ARM namespace, two Terraform resource types. A name used by an anomaly alert is not available here:
report-prod-monthly -> this resource
anomaly-prod-daily -> the sibling
✅ Prefix by kind. It prevents the collision and reads correctly in an email subject line too.
⚠️ This resource documents no character rule forname, and the module does not invent one — it checks only non-emptiness and rejects a Resource ID. The sibling's lowercase-only rule is documented, so it is enforced there. The asymmetry is stated in both modules rather than smoothed over.
✅ The message cap is the same on both: 250 characters, which is what Azure documents. An earlier version of this suite recorded the sibling as 100; that was a module-side cap, not a provider or service one. Text shared between the two must fit the smaller limit.
10 · Force-new, and what a review should assert
# 🔴 Force-new:
name = "report-prod-monthly-v2"
view_id = var.other_view_id
# ✅ Updates in place — including everything about the schedule:
display_name = "Production monthly cost report (FinOps)"
email_addresses = ["cloud-cost-alerts@contoso.example", "finops@contoso.example"]
email_address_sender = "cloud-cost-alerts@contoso.example"
email_subject = "Prod monthly cost"
message = "Owner: FinOps. Questions to #cloud-cost."
frequency = "Weekly"
days_of_week = ["Monday"]
start_date = "2026-08-01T00:00:00Z"
end_date = "2028-01-31T00:00:00Z" # ✅ extending is cheap
hour_of_day = 7✅ Only two fields are force-new, and the entire schedule is mutable — which is exactly the right shape, because extending
end_dateis the operation you will perform most (example 2).
🔴 But a force-new change re-parents the creator (example 3), so
nameandview_idedits should come from CI too.
output "scheduled_action_review" {
value = {
id = module.monthly_report.id
view = module.monthly_report.view_id # 🔴 go and look at it
when = module.monthly_report.schedule_summary # ✅ one readable line
dead = module.monthly_report.ignored_schedule_fields # ✅ expect []
expires = module.monthly_report.end_date # 🔴 diarise this
who = module.monthly_report.email_addresses # 🔒 a mailbox, not people
unsub = module.monthly_report.email_address_sender
# 🔴 Prompts, not values:
expiry_warning = module.monthly_report.stops_sending_after_end_date
creator_warning = module.monthly_report.delivery_depends_on_the_creators_access
disclosure = module.monthly_report.recipients_need_no_azure_access
view_warning = module.monthly_report.view_must_exist_and_is_not_created_here
}
}✅
deadandwhenare the two worth automating. An emptyignored_schedule_fieldsproves the schedule you wrote is the schedule that runs, andschedule_summaryis the line to put in front of whoever asked for the report.
11 · Destroy, locks and importing
terraform destroy on this module:
removes the SCHEDULE -> and the reports stop
the view survives — it was never owned here
historical cost data is untouched
✅ A low-consequence destroy — no data is lost. What you lose is a report, whose absence is easy to miss (which is the theme of this whole module).
⚠️ prevent_destroyis not available, becauselifecycleis not valid inside amoduleblock. ACanNotDeletemanagement lock is the alternative.
Importing an existing scheduled action
terraform import 'module.monthly_report.azurerm_cost_management_scheduled_action.this' \
"/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.CostManagement/scheduledActions/report-prod-monthly"🔴 Importing does not change the creator. A schedule created by hand keeps its original creator's access dependency, so the only way to re-parent it to a service principal is to destroy and recreate it from CI (example 3).
⚠️ Restatenameandview_idexactly; both are force-new (example 10).
⚠️ Checkend_dateimmediately after importing. A hand-made schedule may be days from expiry, and nothing in the import will mention it (example 2).
12 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
# 🔴 Apply from CI as a service principal. That identity becomes the schedule's
# creator, and its continuing Reader access keeps the reports arriving (example 3).
}
# ── The creating identity's own access, kept where it is visible ─────────────
module "cost_reader" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000"
role_assignments = {
pipeline_reader = {
role_definition_name = "Reader"
principal_id = var.pipeline_principal_object_id
}
}
}
# ── The view: what the report actually contains (example 4) ──────────────────
# ℹ️ Built by terraform-azurerm-subscription-cost-management-view, whose own
# required inputs (timeframe, chart type and dataset among them) are covered in
# that module's documentation and are omitted here rather than half-stated.
# ⚠️ Whatever you build there must be neither a table view nor management-group
# scoped — neither can be subscribed to.
# module "cost_view" {
# source = ".../terraform-azurerm-subscription-cost-management-view..."
# name = "prod-monthly-by-service"
# ...
# }
# ── This module: the schedule and the recipients ─────────────────────────────
module "monthly_report" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cost-management-scheduled-action.git?ref=v1.0.0"
# ⚠️ Prefixed by kind: the namespace is shared with anomaly alerts (example 9).
name = "report-prod-monthly"
display_name = "Production monthly cost report"
# 🔴 Force-new, passed as an attribute so the view outlives the schedule (example 4).
view_id = var.cost_view_id
# 🔒 One monitored shared mailbox. Recipients need no Azure access (example 8).
email_addresses = ["cloud-cost-alerts@contoso.example"]
email_address_sender = "cloud-cost-alerts@contoso.example" # ⚠️ unsubscribe, not From
email_subject = "Prod monthly cost report" # ≤ 70 (example 9)
message = "Owner: FinOps. Questions to #cloud-cost." # ≤ 250 here (example 9)
# ✅ Monthly via day_of_month, which satisfies the documented either/or (example 5).
frequency = "Monthly"
day_of_month = 1 # ✅ 1 rather than 31: every month has it (example 7)
hour_of_day = 8 # ⚠️ UTC (example 7)
start_date = "2026-08-01T00:00:00Z"
# 🔴 Driven by a variable the pipeline refreshes, so expiry is renewed rather than
# remembered (example 2).
end_date = var.cost_report_end_date
}
# ── The anomaly counterpart: surprises rather than routine ───────────────────
module "anomaly_alert" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-cost-anomaly-alert.git?ref=v1.0.0"
name = "anomaly-prod-daily" # ⚠️ different prefix (example 9)
display_name = "Production daily cost anomaly"
email_addresses = ["cloud-cost-alerts@contoso.example"]
email_subject = "Prod cost anomaly detected"
}
output "cost_reporting_posture" {
value = {
schedule = module.monthly_report.schedule_summary # ✅ one readable line
dead = module.monthly_report.ignored_schedule_fields # ✅ expect []
expires = module.monthly_report.end_date # 🔴 diarise
view = module.monthly_report.view_id
# 🔴 Runbook facts, not statuses:
expiry_warning = module.monthly_report.stops_sending_after_end_date
creator_warning = module.monthly_report.delivery_depends_on_the_creators_access
}
}🔒 What the composition gets right: applied from CI so the creator is a service principal, that principal's
Readerkept in the same configuration,end_datedriven by a pipeline-refreshed variable so expiry is renewed rather than remembered,day_of_month = 1because every month has one, the view passed as an attribute so it outlives the schedule, names prefixed by kind so the shared namespace cannot collide, and the routine report paired with an anomaly alert so the two concerns stay separate.
⚠️ Note the anomaly alert references nothing from this module. They share a namespace and a mailbox, not a dependency.
🔴 What still needs a human: confirming the view is neither a table view nor management-group scoped (example 4); checking that the billing-side view charges policy is enabled for indirect EA or MCA agreements, or no email is sent at all; and noticing if reports stop, since neither silent failure mode announces itself.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
— | Required. Force-new. No documented character rule. |
display_name |
string |
— | Required. Updates in place. |
view_id |
string |
— | Required. Force-new. 🔴 Decides the content. |
email_addresses |
list(string) |
— | Required, ≥1. 🔒 A disclosure boundary. |
email_address_sender |
string |
— | Required. |
email_subject |
string |
— | Required. ≤70 characters. |
message |
string |
null |
≤250 — the sibling allows 100. |
frequency |
string |
— | Required. Daily/Weekly/Monthly. Carries the conditionals. |
start_date |
string |
— | Required. RFC3339 UTC. |
end_date |
string |
— | Required. 🔴 The schedule stops here. |
day_of_month |
number |
null |
1–31. Monthly only. |
days_of_week |
list(string) |
null |
Monday–Sunday. Weekly, or Monthly with weeks. |
weeks_of_month |
list(string) |
null |
First–Fourth, Last. Monthly only. |
hour_of_day |
number |
null |
0–23. |
timeouts |
object(...) |
null |
All four exist; none affects delivery. |
ℹ️ There is no
tagsvariable, because the provider exposes notagsattribute on this resource.
Full schemas
variable "frequency" {
type = string
# Required. The provider DOCUMENTS conditional requirements, so they are enforced — and both checks are placed here,
# one-directionally, because a validation may only reference its own variable. See examples 5 and 6.
validation {
condition = contains(["Daily", "Weekly", "Monthly"], var.frequency)
error_message = "frequency must be exactly one of: Daily, Weekly, Monthly … ℹ️ Note there is no Annually option here, unlike the cost-management *export* resources …"
}
validation {
condition = var.frequency == "Weekly" ? length(coalesce(var.days_of_week, [])) > 0 : true
error_message = "frequency = \"Weekly\" requires days_of_week to be set … ⚠️ This check is placed on `frequency` because a validation may only reference its own variable …"
}
validation {
condition = var.frequency == "Monthly" ? (var.day_of_month != null || (length(coalesce(var.weeks_of_month, [])) > 0 && length(coalesce(var.days_of_week, [])) > 0)) : true
error_message = "frequency = \"Monthly\" requires either day_of_month, or weeks_of_month together with days_of_week … weeks_of_month alone is not enough …"
}
}
variable "end_date" {
type = string
# 🔴 REQUIRED — there is no "never expires", and delivery stops silently once it passes. `end_date > start_date` is
# enforced; Azure's one-year cap is NOT, because the bound is a year from today or start_date whichever is LATER, so a
# check based on start_date alone would reject legal input. See example 2.
validation {
condition = can(formatdate("YYYY-MM-DD", var.end_date)) && can(formatdate("YYYY-MM-DD", var.start_date)) ? timecmp(var.end_date, var.start_date) > 0 : true
error_message = "end_date must be later than start_date. ⚠️ Placed on end_date one-directionally … ℹ️ Azure additionally caps the end date at one year out; that bound depends on when the apply runs, so it is documented rather than enforced."
}
}| Output | Description | Sensitive |
|---|---|---|
id |
The Resource ID. Same type as an anomaly alert. | no |
name / display_name |
Identity. | no |
view_id |
🔴 Force-new. Decides the content. | no |
email_addresses |
🔒 Deliberately un-redacted — example 8. | no |
email_recipient_count |
Recipient count. | no |
email_address_sender |
The unsubscribe contact. | no |
email_subject / message |
Content. | no |
frequency / start_date / end_date |
The schedule. | no |
hour_of_day / day_of_month / days_of_week / weeks_of_month |
Schedule detail. | no |
schedule_summary |
✅ Derived — one readable line. | no |
ignored_schedule_fields |
no | |
stops_sending_after_end_date |
🔴 Always true. |
no |
delivery_depends_on_the_creators_access |
🔴 Always true. |
no |
recipients_need_no_azure_access |
🔒 Always true. |
no |
view_must_exist_and_is_not_created_here |
Always true. |
no |
ℹ️ Nothing here is secret. Email addresses are personal data, not credentials (example 8).
-
This resource has two independent silent failure modes, and the module is organised around them. A schedule can stop because its
end_datepassed, or because the identity that created it lost access — and neither produces an error, a plan diff, or any symptom beyond an email that no longer arrives. Both are constant outputs for that reason. -
The expiry is the more certain of the two, because it is guaranteed.
end_dateis required, Azure caps it at a year, and every schedule therefore has a date on which it stops working. The module names the cheap remedy — the field updates in place — and recommends driving it from a pipeline-refreshed value so renewal is automatic rather than remembered. -
The one-year cap is documented and deliberately not enforced, and the reasoning is worth stating because it looks like an omission: the bound is one year from today or
start_date, whichever is later, so with a paststart_datethe real limit exceeds anything astart_date-based check could allow. Enforcing it would reject legal input.end_date > start_dateis enforced, since that can never be legal. -
The creator-access dependency is improved by using Terraform, which is worth saying out loud. Because the applying identity becomes the creator, applying from CI does what Microsoft recommends without anybody needing to know why — provided the module says so, which it does in the overview, the permissions table, the runbook and an output. The corollary matters too: a force-new change re-parents the creator to whoever applied it.
-
The documented conditional requirements are enforced; the applicability notes are not.
Weeklyrequiringdays_of_weekandMonthlyrequiringday_of_monthor theweeks_of_monthpair are published requirements, so they arevalidation {}blocks.day_of_monthbeing applicable when monthly is not a prohibition — Azure accepts and ignores it — soignored_schedule_fieldsreports it instead. Rejecting it would refuse a working configuration. -
Both conditional checks are placed on
frequency, one-directionally, because avalidation {}condition must reference its own variable and two variables validating each other is rejected as a cycle. Each message says which variable carries the check, so naming a field the caller was not editing does not read as a bug. -
schedule_summaryexists because five fields interact.frequency,weeks_of_month,days_of_week,day_of_monthandhour_of_daycombine differently depending on the first of them, and a reviewer asking "when does this send?" should get an answer rather than a reassembly task. It always states UTC, because that is the mistake. -
view_idis the field that decides whether the module is useful at all, and it is force-new. The two unsupported view kinds — management-group scope and table views — are invisible from a Resource ID, so they are documented as a prerequisite and carried in a constant output. -
recipients_need_no_azure_accessis a privacy statement. Azure documents that recipients need no portal or Cost Management access, so cost data leaves the access-control boundary the moment an address is added. Following this library's rule on privacy-not-secrecy, the module surfaces that and refers retention and lawful basis to whoever owns data protection. -
No character rule is invented for
name. The sibling anomaly-alert resource documents lowercase-only; this one documents nothing, so only non-emptiness and a not-a-Resource-ID check apply. That asymmetry, thelist/setdifference, the inverted message caps and the required-versus-optional unsubscribe contact are all tabulated rather than smoothed over, because a reader will otherwise assume the family is symmetric. -
Duplicates in
days_of_weekandweeks_of_monthare rejected. The provider takes lists rather than sets, so a repeated value type-checks and means nothing to Azure — the kind of no-signal mistake worth catching at plan time.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| A guaranteed silent expiry | constant flag + renewal guidance | — |
| Silent non-delivery | constant flag + apply-from-CI guidance | — |
| Cost data leaving the access boundary | constant flag + un-redacted recipients | — |
| A view that cannot be subscribed to | constant flag emitted | — |
| A documented conditional requirement | enforced, placed, and explained | — |
| An applicability note | reported as ignored_schedule_fields |
— |
| A cap that depends on apply time | documented, not enforced | — |
| An end date before its start | rejected at plan | — |
| A schedule spread across five fields | schedule_summary emitted |
— |
| Meaningless duplicate list entries | rejected at plan | — |
| An undocumented name rule on a sibling | not invented — asymmetry stated | — |
| A recipient list with no recipients | rejected at plan | — |
| A tags tail the provider lacks | omitted, verified against the schema | — |
- Before applying: apply from CI, so the creator is a service principal.
- Before merging: diarise
end_date, or drive it from CI. - Before trusting the report: go and look at the view.
- Before adding a recipient: they will read your cost data with no Azure role.
- Before setting
hour_of_day: it is UTC, and it will not follow daylight saving.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag —
?ref=v1.0.0— never a branch. - Plan-only from here. A human applies from CI.
- 🔴 Extend
end_datebefore it passes, or the reports stop (example 2). - 🔴 Apply from CI, and keep that identity's
Readerin place (example 3). - 🔴 Force-new:
name,view_id— and a replacement re-parents the creator (example 10). - ✅ In place: every schedule field, the recipients, and
end_date. ⚠️ Assertignored_schedule_fieldsis empty (example 6).⚠️ Readschedule_summaryrather than reassembling five fields (example 7).⚠️ Prefix names by kind — the namespace is shared (example 9).⚠️ Allow-listmicrosoft-noreply@microsoft.com(example 8).- 💡 A
CanNotDeletelock matters more than usual: a deleted schedule has no symptom (example 11).
terraform validate and terraform fmt -check are the offline gate. They confirm:
nameis non-empty and is not a Resource ID;view_idends in/providers/Microsoft.CostManagement/views/<view>;email_addresseshas at least one entry and every entry looks like an email address;email_address_senderlooks like an email address;email_subjectis non-empty and at most 70 characters, andmessageat most 250;frequencyis exactlyDaily,WeeklyorMonthly;Weeklyhasdays_of_week, andMonthlyhasday_of_monthor theweeks_of_month+days_of_weekpair;start_dateandend_dateare RFC3339, andend_dateis later thanstart_date;day_of_monthis a whole number 1–31, andhour_of_daya whole number 0–23;days_of_weekandweeks_of_monthcontain only legal, non-duplicated values;- the module declares no
providerblock and notagsvariable.
💡 These were proved by evaluating the conditions in
terraform consoleinside the module — which does fire root-module variable validations, unliketerraform validateon a calling configuration."Weekly"with nodays_of_weekfires the weekly conditional,"Monthly"with onlyweeks_of_monthfires the monthly either/or, anend_datebeforestart_datefires the ordering check,"monday"fires the case check, a duplicated day fires the distinct check, and32fires the day-of-month range.
⚠️ And note a property of the harness: Terraform skips a validation whose referenced variable has already failed — which matters more here than usual, because thefrequencychecks reference four other variables. A short error list is not proof a check is missing.
🔴 What the gate deliberately does not attempt: Azure's one-year
end_datecap (it depends on when the apply runs), the applicability ofday_of_monthon a non-monthly schedule (reported instead), or whether the view supports subscribing.
What only plan and apply exercise:
- whether the view exists and is readable;
- whether the caller may create scheduled actions;
- whether the name is already taken by an anomaly alert (example 9).
What no Terraform command checks at any stage:
- 🔴 whether the creating identity will keep its access (example 3);
- 🔴 whether anybody will notice when
end_datepasses (example 2); - 🔴 whether the view is a table view or management-group scoped (example 4);
⚠️ whether the billing-side view charges policy suppresses the emails (example 12);- whether the recipients should be receiving cost data at all (example 8).
Outputs:
day_of_month = 1
delivery_depends_on_the_creators_access = true
display_name = "Production monthly cost report"
days_of_week = null
email_address_sender = "cloud-cost-alerts@contoso.example"
email_addresses = ["cloud-cost-alerts@contoso.example"]
email_recipient_count = 1
email_subject = "Prod monthly cost report"
end_date = "2027-07-31T00:00:00Z"
frequency = "Monthly"
hour_of_day = 8
id = "/subscriptions/00000000-.../providers/Microsoft.CostManagement/scheduledActions/report-prod-monthly"
ignored_schedule_fields = []
message = "Owner: FinOps. Questions to #cloud-cost."
name = "report-prod-monthly"
recipients_need_no_azure_access = true
schedule_summary = "Monthly day-1 at 08:00 UTC"
start_date = "2026-08-01T00:00:00Z"
stops_sending_after_end_date = true
view_id = "/subscriptions/00000000-.../providers/Microsoft.CostManagement/views/prod-monthly-by-service"
view_must_exist_and_is_not_created_here = true
weeks_of_month = null
✅
schedule_summary = "Monthly day-1 at 08:00 UTC"is the line to show whoever asked for the report — and note it says UTC (example 7).
✅
ignored_schedule_fields = []proves the schedule written is the schedule that runs (example 6).
🔴
end_date = "2027-07-31T00:00:00Z"is the date to diarise. Nothing will remind you (example 2).
ℹ️
days_of_weekandweeks_of_montharenullbecause this monthly schedule usesday_of_month— the other valid route (example 5).
🔴 The four
trueconstants are facts, not statuses. None means "checked and fine".
| Symptom | Cause | Fix |
|---|---|---|
| Reports stopped, nothing changed | end_date passed, or the creator lost access. |
Extend the date; check the creator (examples 2, 3). |
| Reports never arrived at all | The view charges policy is off, or the view is unsupported. | Check billing policy and the view (examples 4, 12). |
Plan rejects frequency = "Weekly" |
days_of_week is missing. |
Supply it (example 5). |
Plan rejects frequency = "Monthly" |
Only weeks_of_month was supplied. |
Add days_of_week, or use day_of_month (example 5). |
| The error names a field I was not editing | The checks are placed on frequency. |
Expected — see example 6. |
Plan rejects end_date |
It is not after start_date. |
Fix the ordering (example 2). |
Apply rejects end_date |
It is more than a year out. | Azure's cap; not checkable offline (example 2). |
ignored_schedule_fields is non-empty |
A schedule field cannot apply to this frequency. | Remove it, or change frequency (example 6). |
| The report arrives an hour early or late | hour_of_day is UTC and does not follow DST. |
Expected (example 7). |
| No report in February | day_of_month is 29–31. |
Use 28 or lower (example 7). |
Plan rejects days_of_week |
Wrong case, or a duplicate. | Monday…Sunday, distinct (example 6). |
| Apply fails on a name conflict | An anomaly alert already uses the name. | Prefix by kind (example 9). |
Plan rejects message |
Over 250 characters. | Azure's documented limit; the sibling resource is the same (example 9). |
Plan rejects view_id |
Not a .../views/<view> ID. |
Pass the view's id (example 4). |
| Reordering recipients produced a diff | It is a list, not a set. |
Keep it sorted (example 8). |
| A recipient without Azure access read the data | That is how the resource works. | Review the list as disclosure (example 8). |
Wanted prevent_destroy |
lifecycle is not valid inside a module block. |
Use a CanNotDelete lock (example 11). |
azurerm_cost_management_scheduled_action— provider documentation, including the conditional requirements of example 5 and the length caps of example 9.- Save and share customized views — the creator-access behaviour of example 3, the one-year
end_datecap and silent expiry of example 2, the unsupported view kinds of example 4, and the view charges policy of example 12. - What is Microsoft Cost Management — how scheduled alerts sit alongside budgets, anomalies and exports, and the note that recipients need no portal access (example 8).
- Cost Management scheduled action modules — Microsoft's own framing of the schedule/anomaly pair.
- Sibling modules:
terraform-azurerm-cost-anomaly-alert(unexpected change rather than a schedule — the same ARM namespace, example 9),terraform-azurerm-subscription-cost-management-view(the view of example 4),terraform-azurerm-subscription-cost-management-exportandterraform-azurerm-billing-account-cost-management-export(data rather than a chart),terraform-azurerm-role-assignments(the creating identity'sReader, example 12). - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."