diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 33fc20309..0ea6747d7 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -7,6 +7,10 @@ on: # KQL sources are covered by unit tests (HubsKqlOperators.Tests.ps1) - 'src/templates/finops-hub/**/*.kql' - 'src/queries/**/*.kql' + # ADF trigger time zone mappings are covered by unit tests (HubsAdfTriggerTimeZones.Tests.ps1) + - 'src/templates/finops-hub/**/timeZones.bicep' + - 'docs/deploy/finops-hub-latest.json' + - 'docs/deploy/finops-hub-preview.json' jobs: run_pester_tests: name: Pester diff --git a/docs/deploy/finops-hub-14.0.json b/docs/deploy/finops-hub-14.0.json index f9d71a4ab..5d526c052 100644 --- a/docs/deploy/finops-hub-14.0.json +++ b/docs/deploy/finops-hub-14.0.json @@ -17311,8 +17311,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", @@ -26318,8 +26318,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", diff --git a/docs/deploy/finops-hub-latest.json b/docs/deploy/finops-hub-latest.json index f9d71a4ab..5d526c052 100644 --- a/docs/deploy/finops-hub-latest.json +++ b/docs/deploy/finops-hub-latest.json @@ -17311,8 +17311,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", @@ -26318,8 +26318,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", diff --git a/docs/deploy/finops-hub-preview.json b/docs/deploy/finops-hub-preview.json index 394756163..1ac3fa3dd 100644 --- a/docs/deploy/finops-hub-preview.json +++ b/docs/deploy/finops-hub-preview.json @@ -3894,9 +3894,9 @@ "timezoneobject": { "type": "object", "defaultValue": { - "australiaeast": "Australian Eastern Standard Time", - "australiasoutheast": "Australian Eastern Standard Time", - "brazilsouth": "Brasil Standard Time", + "australiaeast": "AUS Eastern Standard Time", + "australiasoutheast": "AUS Eastern Standard Time", + "brazilsouth": "E. South America Standard Time", "canadacentral": "Central Standard Time", "canadaeast": "Eastern Standard Time", "centralindia": "India Standard Time", @@ -3904,26 +3904,26 @@ "eastasia": "China Standard Time", "eastus": "Eastern Standard Time", "eastus2": "Eastern Standard Time", - "francecentral": "Central European Time", - "germanynorth": "Central European Time", - "germanywestcentral": "Central European Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "francecentral": "W. Europe Standard Time", + "germanynorth": "W. Europe Standard Time", + "germanywestcentral": "W. Europe Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", - "northeurope": "Central European Time", - "norwayeast": "Central European Time", - "norwaywest": "Central European Time", + "northeurope": "GMT Standard Time", + "norwayeast": "W. Europe Standard Time", + "norwaywest": "W. Europe Standard Time", "southcentralus": "Central Standard Time", "southindia": "India Standard Time", "southeastasia": "Singapore Standard Time", - "switzerlandnorth": "Central European Time", - "switzerlandwest": "Central European Time", - "uksouth": "Greenwich Mean Time", - "ukwest": "Greenwich Mean Time", + "switzerlandnorth": "W. Europe Standard Time", + "switzerlandwest": "W. Europe Standard Time", + "uksouth": "GMT Standard Time", + "ukwest": "GMT Standard Time", "westcentralus": "Central Standard Time", - "westeurope": "Central European Time", + "westeurope": "W. Europe Standard Time", "westindia": "India Standard Time", "westus": "Pacific Standard Time", "westus2": "Pacific Standard Time" diff --git a/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 new file mode 100644 index 000000000..fcba2a4b4 --- /dev/null +++ b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Regression coverage for the ADF schedule trigger time zone mappings (PR #2236): + + Data Factory schedule triggers validate the timeZone field against Windows time zone IDs + (the registry IDs enumerated by [TimeZoneInfo]::GetSystemTimeZones() / Get-TimeZone -ListAvailable). + A display name or IANA-style ID (e.g. 'Japan Standard Time' instead of the Windows ID + 'Tokyo Standard Time') passes deployment but fails at trigger activation with: + ErrorCode=InvalidWorkflowTriggerRecurrence, ErrorMessage=The recurrence of trigger has an invalid time zone '...'. + See: https://learn.microsoft.com/azure/data-factory/how-to-create-schedule-trigger#time-zone-option + + These tests parse timeZones.bicep and verify: + 1. Every mapped value resolves in the Windows time zone registry, with exact casing (Windows runners only). + 2. No value is an IANA-style ID (contains '/'), which .NET on Linux would accept but ADF rejects. + 3. Every region key matches the lookup normalization (lowercase, no spaces) and is a known Azure region. + 4. The fallback stays pinned to the valid Windows ID 'UTC' (not a display name). + + The same value checks also run against the compiled deployment templates users actually deploy from + (docs/deploy/finops-hub-latest.json and finops-hub-preview.json), because those are regenerated at + release time and can lag a src fix (see the #2236 review discussion). Older versioned templates are + historical artifacts and intentionally not covered. +#> + +Describe 'HubsAdfTriggerTimeZones' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $bicepPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/timeZones.bicep' + $bicepLines = Get-Content -Path $bicepPath + + # Extract the region -> time zone entries from the timezoneobject param block. + $mappings = @() + $inBlock = $false + foreach ($line in $bicepLines) + { + if ($line -match '^param timezoneobject object = \{') { $inBlock = $true; continue } + if ($inBlock -and $line -match '^\}') { break } + if ($inBlock -and $line -match "^\s*([^\s:/]+)\s*:\s*'([^']*)'") + { + $mappings += @{ Region = $Matches[1]; TimeZoneId = $Matches[2] } + } + } + + $distinctTimeZones = @($mappings | ForEach-Object { $_.TimeZoneId } | Sort-Object -Unique | ForEach-Object { @{ TimeZoneId = $_ } }) + + # Extract the same mapping from the compiled deployment templates (shipped artifacts). + # Each template embeds the timezoneobject param default in nested deployments; the blocks are + # identical, so collapse to unique (File, Region, TimeZoneId) tuples. + $deployTemplates = @('finops-hub-latest.json', 'finops-hub-preview.json') + $deployMappings = @() + $deployBlockCounts = @() + foreach ($templateName in $deployTemplates) + { + $templateContent = Get-Content -Path (Join-Path $repoRoot "docs/deploy/$templateName") -Raw + $blocks = [regex]::Matches($templateContent, '"timezoneobject"\s*:\s*\{\s*"type"\s*:\s*"object",\s*"defaultValue"\s*:\s*\{([^}]*)\}') + $deployBlockCounts += @{ File = $templateName; BlockCount = $blocks.Count } + $seen = @{} + foreach ($block in $blocks) + { + foreach ($pair in [regex]::Matches($block.Groups[1].Value, '"([^"]+)"\s*:\s*"([^"]*)"')) + { + $key = $pair.Groups[1].Value + '|' + $pair.Groups[2].Value + if (-not $seen.ContainsKey($key)) + { + $seen[$key] = $true + $deployMappings += @{ File = $templateName; Region = $pair.Groups[1].Value; TimeZoneId = $pair.Groups[2].Value } + } + } + } + } + } + + BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $bicepPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/timeZones.bicep' + $bicepContent = Get-Content -Path $bicepPath -Raw + + # Known Azure region IDs from toolkit open data. Loaded in BeforeAll (run phase) because + # BeforeDiscovery variables are not visible inside It blocks at run time. + $regionIds = @{} + Import-Csv (Join-Path $repoRoot 'src/open-data/Regions.csv') | ForEach-Object { $regionIds[$_.RegionId] = $true } + } + + Context 'Parsing' { + It 'Finds the timezoneobject mappings in timeZones.bicep' -TestCases @(@{ MappingCount = @($mappings).Count }) { + # Guards the parser itself: if the bicep layout changes, fail loudly instead of green-running zero cases. + # Count is captured at discovery time (BeforeDiscovery variables are not visible at run time). + $MappingCount | Should -BeGreaterThan 30 + } + + It 'Finds the timezoneobject blocks in ' -TestCases $deployBlockCounts { + # Same guard for the compiled templates: layout changes must not silently drop coverage. + $BlockCount | Should -BeGreaterThan 0 + } + } + + Context 'Windows time zone registry' { + It 'Maps to a Windows time zone ID that exists in the registry: ' -TestCases $mappings -Skip:(-not $IsWindows) { + # Get-TimeZone -Id emits a non-terminating error for unknown IDs; -ErrorAction Stop makes it throw. + $resolved = Get-TimeZone -Id $TimeZoneId -ErrorAction Stop + # Exact casing: the registry lookup is case-insensitive, but pin the canonical ID so the + # file never drifts from what ADF documents and the portal dropdown emits. + $resolved.Id | Should -BeExactly $TimeZoneId + } + } + + Context 'Compiled deployment templates' { + It ': maps to a Windows time zone ID that exists in the registry: ' -TestCases $deployMappings -Skip:(-not $IsWindows) { + $resolved = Get-TimeZone -Id $TimeZoneId -ErrorAction Stop + $resolved.Id | Should -BeExactly $TimeZoneId + } + } + + Context 'Time zone ID format' { + It 'Value "" is not an IANA-style or display-name ID' -TestCases $distinctTimeZones { + $TimeZoneId | Should -Not -BeNullOrEmpty + # IANA IDs (Area/Location) resolve in .NET on Linux but are rejected by ADF trigger activation. + $TimeZoneId | Should -Not -Match '/' + # Known-bad display names that read like valid IDs but are not in the Windows registry. + $displayNames = @('Japan Standard Time', 'Universal Coordinated Time', 'Coordinated Universal Time') + $displayNames | Should -Not -Contain $TimeZoneId + } + } + + Context 'Region keys' { + It 'Region key "" matches the toLower/no-space lookup normalization' -TestCases $mappings { + # timeZones.bicep resolves timezoneobject[toLower(replace(location, ' ', ''))]. + $Region | Should -BeExactly $Region.ToLowerInvariant() + $Region | Should -Not -Match '\s' + } + + It 'Region key "" is a known Azure region' -TestCases $mappings { + $regionIds.ContainsKey($Region) | Should -BeTrue -Because "region '$Region' should exist in src/open-data/Regions.csv" + } + } + + Context 'Fallback' { + It "Pins the fallback to the valid Windows ID 'UTC'" { + # 'UTC' is a real Windows time zone ID; the display name 'Universal Coordinated Time' is not. + $bicepContent | Should -Match "\?\?\s*'UTC'" + } + } +}