Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure DevTest Lab Schedule Terraform Module

Creates an azurerm_dev_test_schedule — the timetable that shuts a DevTest Lab's virtual machines down, or starts them up. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🕒 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, a Disabled status, 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.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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.


✅ Provider / Versions

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 with commonschema.Location() and azure.SchemaResourceGroupNameDiffSuppress(), which set ForceNew internally — so a grep of the resource file finds only two of the four.
  • task_type has 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, ConflictsWith or CustomizeDiff appears on this resource at all.
  • time_zone_id is Required, 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_minutes is transmitted as 0 when omitted, because the expand sends a plain integer's address unconditionally — while the field's own IntAtLeast(1) rejects an explicit 0.
  • resource_group_name case differences produce no diff on this family, because the API returns the value lower-cased (Azure/azure-rest-api-specs issue 3964).
  • Create and Update are the same function. The payload is rebuilt rather than patched, so removing a recurrence genuinely clears it.
  • tags is freely updatable here — worth saying, because several resources in this suite make it force-new.

🔑 Required Azure RBAC Roles / Permissions

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 User is 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.


Azure Prerequisites

  1. Microsoft.DevTestLab registered in the subscription.
  2. An existing DevTest Lab in the subscription the caller's provider targets, in the region passed as location.
  3. 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.
  4. A webhook endpoint, if notifications are wanted — this resource cannot send email.
  5. A task_type value Azure recognises. The provider checks nothing.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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 to LabVmsShutdown or LabVmAutoStart, 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_inactive is true. 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_days must 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_in exists 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 emailRecipient property, described as required when webhookUrl is empty — the azurerm resource has no email field at all, although its azurerm_dev_test_global_vm_shutdown_schedule sibling does -- see terraform-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 explicit 0. 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. Read notification_lead_time_minutes (the value Azure receives) beside notification_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_recurrences and recurrence_count exist 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, through has_no_recurrence_so_the_schedule_never_fires. Example 12 shows the check block 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
LabVmsShutdownTask ARM template reference, Azure SDK for JavaScript, and a Bicep parameter's description
LabVmAutoStart ARM template reference, Azure SDK for JavaScript, and a Bicep allowed-values list
LabVmsShutdown a Bicep allowed-values list
LabVmsStartupTask the 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_spelling is 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_url arrives 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.


📥 Inputs

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.


🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. Everything above is plan-only static analysis; a human applies from CI.


🧪 Testing

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.


💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


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