Creates an
azurerm_dev_test_schedule— the timetable that shuts a DevTest Lab's virtual machines down, or starts them up. Targetshashicorp/azurerm ~> 4.0.
- 🕒 Creates a lab-wide schedule that stops or starts the lab's virtual machines on a daily, weekly or hourly timetable.
- 💰 The primary cost control for a DevTest Lab: idle machines that shut themselves down are the single largest saving a lab offers.
- 🔔 Configures advance notification through a webhook, the only channel this resource can express.
- 🧭 Emits posture flags for the several ways this resource can be created, validate cleanly, and do nothing at all.
💡 Why it matters: every one of this resource's failure modes is silence. A schedule with a misspelled
task_type, no recurrence, aDisabledstatus, or an autostart task whose VMs never opted in is created successfully, reports healthy in every future plan, and never acts. The provider validates almost none of it, so the module's job is to make the absence of effect visible before the apply.
If this module saved you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
LAB["terraform-azurerm-dev-test-lab"]
SCHED["terraform-azurerm-dev-test-schedule"]
POLICY["terraform-azurerm-dev-test-policy: caps VM count and size in the lab"]
VMS["terraform-azurerm-dev-test-linux-virtual-machine and its Windows twin"]
GLOBAL["terraform-azurerm-dev-test-global-vm-shutdown-schedule: the per-VM equivalent for ordinary ARM virtual machines"]
OPTIN["the per-VM autostart opt-in, which has NO Terraform resource in any provider"]
RG -->|"resource_group_name and location"| LAB
RG -->|"resource_group_name"| SCHED
LAB -->|"name, NOT id, so there is no dependency edge from a literal"| SCHED
LAB -->|"lab_name and lab_subnet_name"| VMS
POLICY -->|"lab_name"| LAB
SCHED -->|"acts on every VM in the lab by policy, naming none of them"| VMS
OPTIN -->|"required per VM before an autostart schedule starts anything"| VMS
GLOBAL -->|"the alternative when a VM is not in a lab"| VMS
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class SCHED me
class VMS keystone
class RG,LAB,POLICY,GLOBAL,OPTIN sibling
Every sibling in this family is authored, and terraform-azurerm-dev-test-global-vm-shutdown-schedule closed it. Note the two facts the diagram carries that no argument does: this schedule acts on virtual machines it never references, and an autostart schedule depends on a per-VM opt-in that has no Terraform resource in any provider.
flowchart TB
IN_ID["lab_name plus resource_group_name plus location: which lab, and where"]
IN_TASK["task_type: what the schedule does. NO provider validation, four published spellings"]
IN_STATUS["status: defaults to Disabled, the provider's own default"]
IN_TZ["time_zone_id: a Windows time zone. The provider's list includes the empty string"]
IN_REC["weekly_recurrence, daily_recurrence, hourly_recurrence: all three optional, none required"]
IN_NOTIF["notification_settings: the block is REQUIRED, every field inside it optional"]
THIS["azurerm_dev_test_schedule.this"]
OUT_ID["id and name"]
OUT_EFF["effective values: location normalized, notification_lead_time_minutes as transmitted"]
OUT_POSTURE["posture flags: is_created_but_inactive, has_no_recurrence_so_the_schedule_never_fires, task_type_matches_no_published_spelling"]
OUT_CONST["eight constants: the autostart opt-in gap, the missing email channel, the reach over every lab VM"]
IN_ID --> THIS
IN_TASK --> THIS
IN_STATUS --> THIS
IN_TZ --> THIS
IN_REC -->|"dynamic blocks, at most one each"| THIS
IN_NOTIF -->|"webhook is the only channel this resource can express"| THIS
THIS --> OUT_ID
THIS --> OUT_EFF
THIS --> OUT_POSTURE
THIS --> OUT_CONST
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class THIS keystone
class OUT_POSTURE,OUT_CONST me
class IN_ID,IN_TASK,IN_STATUS,IN_TZ,IN_REC,IN_NOTIF,OUT_ID,OUT_EFF sibling
Resource inventory: one azurerm_dev_test_schedule named this. Three optional recurrence blocks rendered with dynamic, one required notification_settings block, and the universal tags + timeouts tail.
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 (pinned; v5.0 deliberately excluded) |
| Provider block | None here — the caller configures provider "azurerm" { features {} }, auth and subscription |
| Module type | standalone (one keystone this, no children) |
Schema notes that bite — each confirmed against the provider's source at the pinned version, not guessed:
- Force-new:
name,lab_name,location,resource_group_name. The last two are built withcommonschema.Location()andazure.SchemaResourceGroupNameDiffSuppress(), which setForceNewinternally — so a grep of the resource file finds only two of the four. task_typehas no validator, no default and no closed set. Any string reaches Azure. A value Azure does not recognise creates a schedule that never acts.- Four first-party sources publish four different spellings for
task_type, and one Microsoft Bicep parameter's description contradicts its own allowed-values list. All of them hedge with "e.g." — which is why this module reports rather than closes the set. - All three recurrence blocks are optional and nothing ties them together. No
AtLeastOneOf,ExactlyOneOf,ConflictsWithorCustomizeDiffappears on this resource at all. time_zone_idisRequired, its validator explicitly permits"", and the expand then drops the field. The provider's own candidate list begins with the empty string.- The time-of-day regex permits a single-digit hour, so
"930"is as legal as"0930". A tidier hand-written rule would reject it. notification_settings.time_in_minutesis transmitted as0when omitted, because the expand sends a plain integer's address unconditionally — while the field's ownIntAtLeast(1)rejects an explicit0.resource_group_namecase differences produce no diff on this family, because the API returns the value lower-cased (Azure/azure-rest-api-specsissue 3964).- Create and Update are the same function. The payload is rebuilt rather than patched, so removing a recurrence genuinely clears it.
tagsis freely updatable here — worth saying, because several resources in this suite make it force-new.
| Principal | Scope | Requirement |
|---|---|---|
| The principal running Terraform | The lab or its resource group | Microsoft.DevTestLab/labs/schedules/* — DevTest Labs Owner, Contributor or Owner |
| Lab users whose VMs the schedule stops | — | No permission at all — the schedule acts on their machines regardless |
⚠️ DevTest Labs Useris not sufficient, despite the name. It is a consumption role: it permits creating and using VMs in a lab, not configuring the lab's policies.
🔒 The interesting question here is reach, not read-versus-write. Whoever can write a schedule can stop every virtual machine in the lab on a timetable — including machines owned by teams who cannot see this configuration. Separating lab ownership from shutdown-policy ownership is an organisational act that neither Azure nor this module performs.
Plan access is not credential access here — this module reads no secret. The one caveat: a notification webhook_url may carry its authorization in its query string, and that value sits in state and in plan output in plaintext. See Architecture Notes.
Microsoft.DevTestLabregistered in the subscription.- An existing DevTest Lab in the subscription the caller's provider targets, in the region passed as
location. - For an autostart schedule: every VM opted in individually. Microsoft: "To implement autostart, you first configure an autostart policy for the lab. You can then enable the policy for individual lab VMs." No Terraform resource expresses that step.
- A webhook endpoint, if notifications are wanted — this resource cannot send email.
- A
task_typevalue Azure recognises. The provider checks nothing.
terraform-azurerm-dev-test-schedule/
├── providers.tf # required_version + pinned azurerm; no provider block
├── variables.tf # 13 deeply-typed inputs, 19 validations
├── main.tf # 26 locals + the keystone azurerm_dev_test_schedule.this
├── outputs.tf # 37 outputs: 8 passthrough, 21 derived, 8 constant
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
The smallest real call — a nightly shutdown at 19:00, and note it is created Disabled, because that is the provider's own default:
module "lab_autoshutdown" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled" # the provider defaults to Disabled; type this to make the schedule act
daily_recurrence = { time = "1900" }
notification_settings = {}
}ℹ️ The caller configures the provider, its authentication and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source |
|---|---|---|
lab_name |
string |
terraform-azurerm-dev-test-lab output name |
resource_group_name |
string |
terraform-azurerm-resource-group output name |
location |
string |
terraform-azurerm-resource-group output location (the lab's region) |
task_type |
string |
caller — an open set |
time_zone_id |
string |
caller — a Windows time zone identifier |
Emits
| Output | Consumed by |
|---|---|
id |
imports, management locks |
name, lab_name, task_type, status |
reporting, assertions against the lab module |
is_created_but_inactive, has_no_recurrence_so_the_schedule_never_fires |
governance check blocks |
| the eight constants | documentation, check blocks |
1 · Nightly shutdown, enabled
module "nightly" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
daily_recurrence = { time = "1900" }
notification_settings = {}
}💡
"LabVmsShutdown"is used for both the schedule's name and its task type. That is deliberate: Microsoft's published Bicep module restricts the name toLabVmsShutdownorLabVmAutoStart, because those are the names the lab's Configuration and policies blade reads.
2 · The default is Disabled, and a disabled schedule is invisible
module "staged" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
# status omitted -> "Disabled", the provider's own default
daily_recurrence = { time = "1900" }
notification_settings = {}
}
⚠️ This applies cleanly and stops nothing.module.staged.is_created_but_inactiveistrue. The module keeps the provider's default rather than flipping it, because switching a caller's virtual machines off on their first apply is an availability decision, not a security one.
3 · Weekdays only
module "weekdays" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
weekly_recurrence = {
time = "1830"
week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
notification_settings = {}
}🔒
week_daysmust be non-empty when a weekly block is set. The provider marks it optional and transmits an empty weekday array, which produces a schedule that never fires on any day — so this module rejects it rather than letting it apply.
4 · Autostart, and the step Terraform cannot take
module "morning_start" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmAutoStart"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmAutoStart"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
weekly_recurrence = {
time = "0800"
week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
notification_settings = {}
}
⚠️ This starts nothing until each virtual machine is opted in individually. Microsoft's model is two-step by design, "to prevent unnecessary startups that could increase costs", and the per-VM flag has no Terraform representation in any provider. Do it in the portal (each VM → Auto-start → Yes) or through the CLI, and expect the VM to read Opted-in afterwards.module.morning_start.lab_autostart_applies_to_no_vms_until_each_one_opts_inexists to keep this in front of a reviewer.
5 · Notification through a webhook
module "notified" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
daily_recurrence = { time = "1900" }
notification_settings = {
status = "Enabled"
time_in_minutes = 30
webhook_url = "https://prod-12.eastus.logic.azure.com/workflows/abc/triggers/manual/paths/invoke"
}
}ℹ️ A webhook is the only channel this resource can express. ARM and Microsoft's own Bicep module both document an
emailRecipientproperty, described as required whenwebhookUrlis empty — the azurerm resource has no email field at all, although itsazurerm_dev_test_global_vm_shutdown_schedulesibling does -- seeterraform-azurerm-dev-test-global-vm-shutdown-schedule, which enforces the pairing as a genuine at-least-one-of because both channels exist there. The module enforces "enabled notifications require a webhook" for exactly that reason.
6 · The lead time you did not set is zero
notification_settings = {
status = "Enabled"
webhook_url = "https://example.com/hook"
# time_in_minutes omitted
}
⚠️ The provider reads this field into a plain integer and sends its address unconditionally, so an omitted value is transmitted as 0 minutes of notice — while the same field's own validator rejects an explicit0. This module does not substitute a lead time, because Microsoft publishes none for a lab schedule; inventing one would report a request that was never made. Readnotification_lead_time_minutes(the value Azure receives) besidenotification_lead_time_is_zero_because_it_was_omitted.
7 · A webhook staged before it is switched on
notification_settings = {
status = "Disabled"
time_in_minutes = 15
webhook_url = "https://example.com/hook"
}ℹ️ Accepted, and inert: the provider transmits the webhook regardless of the status, so nobody is notified. This is reported rather than rejected (
notification_is_configured_but_switched_off), because staging an endpoint before enabling it is a reasonable thing to do.
8 · Hourly recurrence, and why it is rarely wanted
module "hourly" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-scratch"
task_type = "LabVmsShutdown"
time_zone_id = "UTC"
status = "Enabled"
hourly_recurrence = { minute = 0 }
notification_settings = {}
}
⚠️ An hourly shutdown stops the lab's machines every hour, on the hour. Legal, occasionally intended for a scratch lab, and almost never what somebody reaching for "shut down when idle" wants. The module reports the shape (has_hourly_recurrence) rather than refusing it.
9 · All three recurrences at once
weekly_recurrence = { time = "2300", week_days = ["Saturday"] }
daily_recurrence = { time = "1900" }
hourly_recurrence = { minute = 15 }ℹ️ Legal — the provider declares no conflict between the three blocks — and the combined behaviour is not documented anywhere.
has_several_recurrencesandrecurrence_countexist so a reviewer sees it, because nothing in a plan flags a configuration that stacks three timetables on one schedule.
10 · A schedule with nothing to trigger it
module "inert" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-labs"
location = "eastus"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
notification_settings = {}
# no recurrence block at all
}
⚠️ Enabled, valid, and it never fires. All three recurrence blocks are optional, ARM and Microsoft's Bicep module both document them as optional, and no provider rule ties them together — so this module reports rather than rejects, throughhas_no_recurrence_so_the_schedule_never_fires. Example 12 shows thecheckblock that makes it fatal.
11 · A task type that matches no published spelling
task_type = "LabVmsShutdownTask" # one of four spellings Microsoft publishesℹ️ The four published spellings, and where each comes from:
Spelling Published by LabVmsShutdownTaskARM template reference, Azure SDK for JavaScript, and a Bicep parameter's description LabVmAutoStartARM template reference, Azure SDK for JavaScript, and a Bicep allowed-values list LabVmsShutdowna Bicep allowed-values list LabVmsStartupTaskthe same Bicep parameter's description, contradicting its own allowed list Every source hedges with "e.g.", and the provider validates nothing — so the module closes no set. It rejects only what is wrong under every reading (blank, whitespace, an embedded space, a Resource ID) and reports the classification.
task_type_matches_no_published_spellingis the flag to assert on; it is not proof a value is wrong, only that it is the value most likely to be ignored by Azure.
12 · Guarding the posture with `check` blocks
check "the_schedule_can_actually_act" {
assert {
condition = !module.nightly.is_created_but_inactive
error_message = "The schedule is either disabled or has no recurrence, so it will never act."
}
}
check "the_task_type_is_one_microsoft_publishes" {
assert {
condition = !module.nightly.task_type_matches_no_published_spelling
error_message = "task_type matches none of the four spellings Microsoft publishes; the provider validates this field not at all."
}
}
check "notifications_reach_somebody" {
assert {
condition = !module.nightly.notification_is_configured_but_switched_off
error_message = "A webhook is configured while notifications are disabled, so nobody is notified."
}
}
check "an_autostart_schedule_is_understood_to_need_a_per_vm_opt_in" {
assert {
condition = !module.nightly.task_type_is_a_published_autostart_spelling
error_message = "This is an autostart schedule. Every VM must be opted in individually, and Terraform cannot do it."
}
}🔒 The last one asserts a constant-shaped fact deliberately: its error text appears in every plan for an autostart schedule, so nobody applies one believing it is self-sufficient.
13 · 🏗️ End-to-end composition
locals {
platform_tags = {
env = "dev"
owner = "platform-engineering"
}
}
module "labs_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-devtest-labs"
location = "eastus2"
tags = local.platform_tags
}
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-platform"
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
tags = local.platform_tags
}
# Autoshutdown: applies to EVERY VM in the lab, with no per-VM step.
module "lab_autoshutdown" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
lab_name = module.lab.name # the NAME, not the id -- this is what creates the dependency edge
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
weekly_recurrence = {
time = "1900"
week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
notification_settings = {
status = "Enabled"
time_in_minutes = 30
webhook_url = var.shutdown_webhook_url
}
tags = local.platform_tags
}
# Autostart: applies to NOTHING until each VM opts in outside Terraform.
module "lab_autostart" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmAutoStart"
resource_group_name = module.labs_rg.name
location = module.labs_rg.location
lab_name = module.lab.name
task_type = "LabVmAutoStart"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
weekly_recurrence = {
time = "0800"
week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
notification_settings = {}
tags = local.platform_tags
}
check "both_schedules_can_act" {
assert {
condition = alltrue([
!module.lab_autoshutdown.is_created_but_inactive,
!module.lab_autostart.is_created_but_inactive,
])
error_message = "One of the two schedules is disabled or has no recurrence."
}
}
output "autostart_still_needs_a_manual_step" {
description = "A reminder that survives into the apply output."
value = module.lab_autostart.the_per_vm_autostart_opt_in_is_not_expressible_in_terraform
}💡 Two schedules, one lab, opposite reach. The shutdown schedule governs every machine in the lab the moment it is enabled. The start schedule governs none of them until somebody visits each VM. The pair is the clearest statement of this resource's central asymmetry, which is why the composition shows both rather than one.
⚠️ webhook_urlarrives through a variable rather than a literal, because a webhook URL often carries its authorization in the query string. It is not marked sensitive — see Architecture Notes.
Identity and placement (4): name, location, resource_group_name, lab_name — all four force-new.
Behaviour (3): task_type, status, time_zone_id.
Recurrence (3): weekly_recurrence, daily_recurrence, hourly_recurrence — all optional.
Notification (1): notification_settings — the block is required.
Universal tail (2): tags, timeouts.
13 variables, 19 validations, distributed 2/1/2/0/3/1/1/2/0/0/3/0/4 in declaration order.
Full schemas and the deliberate rule gaps
| Variable | Rules | Notes |
|---|---|---|
name |
2 | blank/whitespace, and not a path or Resource ID. The provider checks only StringIsNotEmpty, which accepts whitespace. |
location |
1 | blank. Force-new via commonschema.Location(). |
resource_group_name |
2 | blank, and not a Resource ID. Case differences are diff-suppressed on this family. |
lab_name |
none, deliberately | The provider's anchored ^[A-Za-z0-9_-]+$ states the whole rule and already rejects a Resource ID. |
task_type |
3 | blank/whitespace, not a Resource ID, no embedded space. No closed set — four published spellings, all hedged. |
status |
1 | closed set Enabled / Disabled, case-sensitive. |
time_zone_id |
1 | blank — the provider's own value list includes "" and the expand then drops the field. No casing rule, because the provider compares case-insensitively. |
weekly_recurrence |
2 | week_days non-empty, and no repeated day. No rule on time — the provider's exact regex permits "930". |
daily_recurrence |
none, deliberately | Same provider regex as the weekly block. |
hourly_recurrence |
none, deliberately | The provider enforces IntBetween(0, 59). |
notification_settings |
3 | status closed set; a webhook is required when notifications are enabled; a supplied webhook must include a scheme. |
tags |
0 | Freely updatable on this resource. |
timeouts |
4 | Go-duration format on each of the four keys. All four exist on this resource. |
Every none, deliberately above is a decision, not an omission: re-expressing a provider ValidateFunc that already fires at plan time buys wording rather than coverage, and in two of the three cases a hand-written rule would reject input the provider accepts.
| Output | Description |
|---|---|
id |
The schedule's Resource ID (first) |
name, resource_group_name, lab_name, task_type, status, time_zone_id, tags |
As configured |
location |
Normalized as the provider sends it — "East US 2" is reported as eastus2 |
schedule_is_enabled, is_created_but_inactive |
Whether the schedule can act |
task_type_is_a_published_shutdown_spelling, task_type_is_a_published_autostart_spelling, task_type_matches_no_published_spelling |
Classification, compared case-insensitively |
name_is_a_well_known_lab_policy_name |
Whether the name is one of Microsoft's two |
recurrence_count, has_weekly_recurrence, has_daily_recurrence, has_hourly_recurrence, has_several_recurrences |
The recurrence shape |
has_no_recurrence_so_the_schedule_never_fires |
The headline posture flag |
weekly_recurrence_days |
Weekday names as configured |
notifications_are_enabled, notification_lead_time_minutes, notification_lead_time_is_zero_because_it_was_omitted |
The effective notification request |
notification_webhook_is_https, notification_webhook_url_carries_a_query_string, notification_is_configured_but_switched_off |
Webhook posture |
is_tagged |
Whether any tag is set |
email_notification_is_not_expressible_on_this_resource |
constant |
lab_autostart_applies_to_no_vms_until_each_one_opts_in |
constant |
lab_autoshutdown_applies_to_every_lab_vm_by_default |
constant |
the_per_vm_autostart_opt_in_is_not_expressible_in_terraform |
constant |
whether_a_first_apply_is_refused_depends_on_caller_provider_configuration |
constant |
resource_group_name_case_differences_are_suppressed_on_this_resource |
constant |
update_rebuilds_the_whole_payload_so_removing_a_recurrence_clears_it |
constant |
the_schedule_acts_on_vms_it_does_not_reference |
constant |
37 outputs: 8 passthrough, 21 derived, 8 constant. No output is sensitive.
Four force-new arguments, and two of them are invisible to a grep. name and lab_name carry explicit ForceNew: true. location and resource_group_name get theirs from commonschema.Location() and azure.SchemaResourceGroupNameDiffSuppress(), which set it internally — which is why a force-new fact must come from reading the helpers rather than counting flags. Everything else updates in place, and because Create and Update are the same function building a fresh payload, removing a recurrence block genuinely clears it on Azure.
The central asymmetry: one field, two opposite default reaches. task_type chooses between a schedule that governs every virtual machine in the lab the moment it is enabled, and one that governs none of them until each machine is opted in through a portal or CLI step no Terraform resource expresses. Nothing in the schema, the plan or the state distinguishes the two. That is why the module reports the classification rather than treating task_type as an opaque string.
Every failure mode on this resource is silence. A misspelled task type, a Disabled status, an absent recurrence, an empty weekday list, an enabled notification with no webhook, a webhook staged while notifications are off — each of those is accepted by the provider, accepted by Azure, produces no error at any layer and no drift afterwards. Two of them the module can convert into parse-time errors (the empty weekday list, and the missing webhook, because in this provider's shape a webhook is the only channel). The rest are reported, because ARM and Microsoft's own Bicep module document them as legal.
sensitive is not the right tool for the webhook URL. Many webhook endpoints carry their authorization in the query string, which makes the URL itself a credential. Marking it would redact where notifications go from every plan — removing exactly the thing a reviewer needs to check — while leaving the value in state in plaintext, because sensitive = true redacts plan output and does not encrypt state. The module reports notification_webhook_url_carries_a_query_string instead, and the honest control is not putting a bearer token in a field designed to be read. The encrypted, access-controlled backend is what protects it.
The lab is referenced by name, so wire the module output. With a literal lab_name Terraform holds no edge to the lab: it cannot order creation, cannot see a rename, and will happily create a schedule against a lab that does not exist yet. lab_name = module.lab.name is the only thing that fixes it.
The blast radius is the lab, not the configuration. A schedule names no virtual machine. It will stop machines created next month by teams who never read this Terraform, and nothing in the plan, the state or the outputs enumerates them.
This resource has no risky option to close off. Seven of its thirteen inputs are required, and none of them is a security control — task_type, status and the recurrences are scheduling and cost decisions. So this suite's secure-by-default rule has no empty call to harden, and reproducing a defaults table would misrepresent the resource. The table below records what was examined instead.
| Provider default | Kept or changed | Why |
|---|---|---|
status = "Disabled" |
Kept | A schedule that acts is a cost and availability decision, not an exposure one. Defaulting Enabled would stop or start a caller's machines on their first apply. Reported through is_created_but_inactive. |
notification_settings.status = "Disabled" |
Kept | Same reasoning; and notifications need a webhook the module cannot invent. |
time_in_minutes (no default; transmitted as 0) |
Kept, and reported | Microsoft publishes no default lead time for a lab schedule, so any value would be invented. The effective value is emitted instead. |
| all three recurrence blocks optional | Kept | Documented as optional by ARM and by Microsoft's Bicep module. Enforcing at-least-one would be a constraint this suite invented. |
task_type (no default, no validation) |
Kept open | Four published spellings, all hedged. A closed set would risk rejecting a legal value. |
Where a rule is added, it is because the provider is thin rather than because the module disagrees with it: a blank-check on a field whose own validator accepts whitespace, a non-empty weekday list where the provider transmits an empty array, and the webhook pairing that ARM documents and azurerm cannot express.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. Everything above is plan-only static analysis; a human applies from CI.
What validate and fmt cover: every input's type and shape, all 19 validations as declarations, HCL formatting, and that the dynamic blocks render.
What only a variable-validation harness covers: whether each of the 19 rules can actually be reached. This module's rules were proven with seven deliberately-bad fixtures — all 19 fired, with zero condition-evaluation errors — plus four good fixtures driving every one of the 26 locals to more than one value. Three locals are constant by construction (two published-spelling tables and the well-known-name table) and are argued rather than chased.
What only plan exercises: the provider's own validators — the time-of-day regex, the timezone list, the lab-name pattern and IntBetween(0, 59).
What nothing offline can exercise: whether Azure recognises the task_type, whether the lab exists, and whether any virtual machine has opted in to autostart.
id = "/subscriptions/00000000-.../resourceGroups/rg-devtest-labs/providers/Microsoft.DevTestLab/labs/lab-platform/schedules/LabVmsShutdown"
name = "LabVmsShutdown"
location = "eastus2"
lab_name = "lab-platform"
task_type = "LabVmsShutdown"
status = "Enabled"
schedule_is_enabled = true
is_created_but_inactive = false
task_type_is_a_published_shutdown_spelling = true
task_type_matches_no_published_spelling = false
name_is_a_well_known_lab_policy_name = true
recurrence_count = 1
has_weekly_recurrence = true
has_no_recurrence_so_the_schedule_never_fires = false
weekly_recurrence_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
notifications_are_enabled = true
notification_lead_time_minutes = 30
notification_lead_time_is_zero_because_it_was_omitted = false
notification_webhook_is_https = true
notification_webhook_url_carries_a_query_string = false
notification_is_configured_but_switched_off = false
lab_autoshutdown_applies_to_every_lab_vm_by_default = true
the_schedule_acts_on_vms_it_does_not_reference = true
| Symptom | Cause | Fix |
|---|---|---|
| The schedule exists and no VM ever stops | status is Disabled — the provider's default |
Set status = "Enabled"; check is_created_but_inactive |
| Enabled, and still nothing happens | No recurrence block was set | Add daily_recurrence, weekly_recurrence or hourly_recurrence; check has_no_recurrence_so_the_schedule_never_fires |
| An autostart schedule starts no machines | Each VM must opt in individually, outside Terraform | Portal: VM → Auto-start → Yes. There is no Terraform resource for it |
| Azure ignores the schedule entirely | task_type is not a value Azure recognises, and the provider validates nothing |
Use LabVmsShutdown or LabVmAutoStart; check task_type_matches_no_published_spelling |
| Nobody is notified | Notifications enabled with no webhook (now a plan error), or a webhook staged while status is Disabled |
Set notification_settings.status = "Enabled"; check notification_is_configured_but_switched_off |
| Notification arrives with no warning time | time_in_minutes was omitted and the provider transmits 0 |
Set it explicitly; read notification_lead_time_minutes |
week_days must list at least one day |
A weekly block with an empty or absent day list — the provider would transmit an empty array | List the days, or use daily_recurrence |
time_zone_id must not be blank |
The provider's own value list contains "" and its expand drops the field |
Pass a Windows identifier such as "Eastern Standard Time", not an IANA name |
| Editing the lab name or region replaces the schedule | All four identity arguments are force-new | Expected; the schedule is cheap to replace |
| A first apply fails with an import error | A schedule of that name already exists in the lab | terraform import, or rename. Whether the check runs at all depends on caller provider configuration |
| A resource-group casing change shows no diff | This family diff-suppresses it (API returns it lower-cased) | Expected — see resource_group_name_case_differences_are_suppressed_on_this_resource |
- Terraform Registry —
azurerm_dev_test_schedule - Microsoft Learn — Manage lab policies in Azure DevTest Labs
- Microsoft Learn — Automatically start lab VMs with autostart
- Microsoft Learn — Configure autoshutdown for labs and VMs
- ARM template reference —
Microsoft.DevTestLab/labs/schedules - Sibling module —
terraform-azurerm-dev-test-lab, whosenameandlocationthis module consumes. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

