Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Cost Management Scheduled Action Terraform Module

Emails a saved Cost Analysis view on a schedule — and expires, because end_date is required and there is no "never" (azurerm_cost_management_scheduled_action). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • 📧 A recurring email of a saved Cost Analysis view — its chart, and optionally a CSV link.
  • 🔴 Every schedule expires. end_date is 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 Reader before 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_id is force-new.
  • 🔒 Recipients need no Azure access. Azure documents this explicitly, so email_addresses sends cost data outside Azure's access-control boundary.
  • ⚠️ frequency carries documented conditional requirements, which this module enforces: Weekly needs days_of_week; Monthly needs either day_of_month or weeks_of_month with days_of_week.
  • ⚠️ hour_of_day is UTC with no time-zone field, so a "morning report" is only morning for part of the year.
  • ⚠️ The ARM namespace is shared with azurerm_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.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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_date is required and delivery stops once it passes (example 2).
  • 🔴 Delivery depends on the creating identity's continuing access (example 3).
  • 🔴 view_id is 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).
  • ⚠️ frequency carries documented conditional requirements (examples 5, 6).
  • ⚠️ hour_of_day is UTC; there is no time-zone field (example 7).
  • ⚠️ day_of_month accepts 29–31, which do not occur in every month (example 7).
  • ⚠️ email_address_sender is the unsubscribe contact, not the From address (example 8).
  • ⚠️ email_addresses is a list here and a set on the sibling; message allows 250 here and 100 there (example 9).
  • ⚠️ No Annually frequency, unlike this library's cost-management export modules (example 5).
  • ⚠️ Force-new: name, view_id (example 10).
  • ⚠️ No tags attribute, so this module carries no tags variable.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy (example 11).

🔑 Required Azure RBAC Roles / Permissions

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).

Azure Prerequisites

  • 🔴 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_addresses and email_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).

📁 Module Structure

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

⚙️ Quick Start

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.

🔌 Cross-Module Contract

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 ⚠️ Derived. Assert empty. 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

📚 Example Library

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"
}

ℹ️ Daily is the only frequency that needs no schedule detail. Weekly and Monthly both carry documented requirements (examples 5, 6).

🔴 Eight required arguments, and two of them are dates you will forget. end_date has no "never" option, so this schedule stops on 31 July 2027 whether anybody remembers or not (example 2).

⚠️ hour_of_day is 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 tags variable, because the provider exposes no tags attribute 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_date is required. There is no "never expires". Azure additionally caps it at one year from today or from start_date, whichever is later — so you cannot simply set it far enough out to forget about.

✅ The remedy is cheap, because end_date updates 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 enforces end_date > start_date and deliberately does not enforce the one-year cap. The cap is relative to today or start_date, whichever is later, so with a past start_date the real limit is later than any check based on start_date alone 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_date and 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. Editing name or view_id destroys 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 Reader assignment 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_id is 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 — DailyCosts and 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_month alone is not a valid monthly schedule. It needs days_of_week to say which day of that week — "the first week" is not a date.

⚠️ There is no Annually, 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_summary reassembles 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_month is applicable when frequency is Monthly — 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_week and weeks_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 for 8 arrives 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_month accepts 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_summary states 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 message or display_name — otherwise the next person to read the configuration has to work out which one the 8 was.

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_sender is not the From address, despite the name. Mail arrives from microsoft-noreply@microsoft.com regardless; 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_addresses is a list here and a set on 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 for name, 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_date is the operation you will perform most (example 2).

🔴 But a force-new change re-parents the creator (example 3), so name and view_id edits 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
  }
}

✅ dead and when are the two worth automating. An empty ignored_schedule_fields proves the schedule you wrote is the schedule that runs, and schedule_summary is 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_destroy is not available, because lifecycle is not valid inside a module block. A CanNotDelete management 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).

⚠️ Restate name and view_id exactly; both are force-new (example 10).

⚠️ Check end_date immediately 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 Reader kept in the same configuration, end_date driven by a pipeline-refreshed variable so expiry is renewed rather than remembered, day_of_month = 1 because 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.

📥 Inputs

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. ⚠️ Unsubscribe contact, not From.
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. ⚠️ UTC.
timeouts object(...) null All four exist; none affects delivery.

ℹ️ There is no tags variable, because the provider exposes no tags attribute 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."
  }
}

🧾 Outputs

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 ⚠️ Derived. Assert empty. 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).

🧠 Architecture Notes

  • This resource has two independent silent failure modes, and the module is organised around them. A schedule can stop because its end_date passed, 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_date is 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 past start_date the real limit exceeds anything a start_date-based check could allow. Enforcing it would reject legal input. end_date > start_date is 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. Weekly requiring days_of_week and Monthly requiring day_of_month or the weeks_of_month pair are published requirements, so they are validation {} blocks. day_of_month being applicable when monthly is not a prohibition — Azure accepts and ignores it — so ignored_schedule_fields reports it instead. Rejecting it would refuse a working configuration.

  • Both conditional checks are placed on frequency, one-directionally, because a validation {} 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_summary exists because five fields interact. frequency, weeks_of_month, days_of_week, day_of_month and hour_of_day combine 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_id is 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_access is 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, the list/set difference, 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_week and weeks_of_month are 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.

🧱 Design Principles

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.

🚀 Runbook

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_date before it passes, or the reports stop (example 2).
  • 🔴 Apply from CI, and keep that identity's Reader in 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.
  • ⚠️ Assert ignored_schedule_fields is empty (example 6).
  • ⚠️ Read schedule_summary rather than reassembling five fields (example 7).
  • ⚠️ Prefix names by kind — the namespace is shared (example 9).
  • ⚠️ Allow-list microsoft-noreply@microsoft.com (example 8).
  • 💡 A CanNotDelete lock matters more than usual: a deleted schedule has no symptom (example 11).

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • name is non-empty and is not a Resource ID;
  • view_id ends in /providers/Microsoft.CostManagement/views/<view>;
  • email_addresses has at least one entry and every entry looks like an email address;
  • email_address_sender looks like an email address;
  • email_subject is non-empty and at most 70 characters, and message at most 250;
  • frequency is exactly Daily, Weekly or Monthly;
  • Weekly has days_of_week, and Monthly has day_of_month or the weeks_of_month + days_of_week pair;
  • start_date and end_date are RFC3339, and end_date is later than start_date;
  • day_of_month is a whole number 1–31, and hour_of_day a whole number 0–23;
  • days_of_week and weeks_of_month contain only legal, non-duplicated values;
  • the module declares no provider block and no tags variable.

💡 These were proved by evaluating the conditions in terraform console inside the module — which does fire root-module variable validations, unlike terraform validate on a calling configuration. "Weekly" with no days_of_week fires the weekly conditional, "Monthly" with only weeks_of_month fires the monthly either/or, an end_date before start_date fires the ordering check, "monday" fires the case check, a duplicated day fires the distinct check, and 32 fires 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 the frequency checks 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_date cap (it depends on when the apply runs), the applicability of day_of_month on 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_date passes (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).

💬 Example Output

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_week and weeks_of_month are null because this monthly schedule uses day_of_month — the other valid route (example 5).

🔴 The four true constants are facts, not statuses. None means "checked and fine".

🔍 Troubleshooting

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).

🔗 Related Docs

  • 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_date cap 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-export and terraform-azurerm-billing-account-cost-management-export (data rather than a chart), terraform-azurerm-role-assignments (the creating identity's Reader, example 12).
  • This module's SCOPE.md.

💙 "Infrastructure as Code should be standardized, consistent, and secure."