Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: FinOps toolkit changelog
description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more.
author: MSBrett
ms.author: brettwil
ms.date: 09/07/2026
ms.date: 09/09/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -34,6 +34,7 @@ The following section lists features and enhancements that are currently in deve
- Replaced redundant `tolower()` comparisons in hub KQL with case-insensitive operators (`has`, `=~`, `!~`) so the engine can use the term index instead of scanning every row ([#2213](https://github.com/microsoft/finops-toolkit/issues/2213)).
- Replaced whole-term `contains` matches with `has` across hub KQL and the query catalog (resource ID paths, licensing phrases, SKU description terms) and added a per-row operator-equivalence regression harness with unit test coverage ([#2220](https://github.com/microsoft/finops-toolkit/pull/2220)).
- **Fixed**
- Fixed ADF schedule trigger deployment failures (`InvalidWorkflowTriggerRecurrence`) in regions not covered by the region-to-time-zone map (for example, Sweden Central), where the trigger's `startTime` was missing the required `Z` suffix for the `UTC` fallback time zone ([#2157](https://github.com/microsoft/finops-toolkit/issues/2157), [#2291](https://github.com/microsoft/finops-toolkit/pull/2291)).
Comment thread
flanakin marked this conversation as resolved.
- Fixed managed exports failing with an `Unauthorized` error because the Role Based Access Control Administrator role was never assigned to the Data Factory identity. Roles requested by a hub app are now assigned on the publisher storage account even when the app doesn't create the storage account itself ([#2253](https://github.com/microsoft/finops-toolkit/issues/2253)).
- Fixed private-network deployments that Azure Policy blocked when `defaultOutboundAccess` was omitted. Private mode subnets now set it to `false`, while an Azure Files private endpoint supports deployment-script storage and the NAT Gateway provides required container egress ([#2258](https://github.com/microsoft/finops-toolkit/issues/2258), [#2259](https://github.com/microsoft/finops-toolkit/pull/2259)).
- Fixed the `ContractedCost` recompute guard to compare with a null-safe tolerance instead of exact float equality, eliminating millions of no-op rewrites that polluted the `x_SourceValues` audit trail while preserving the null-cost backfill and no longer overwriting an existing cost when the unit price is missing ([#2216](https://github.com/microsoft/finops-toolkit/issues/2216)).
Expand Down
6 changes: 3 additions & 3 deletions docs/deploy/finops-hub-14.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -14033,7 +14033,7 @@
"recurrence": {
"frequency": "Hour",
"interval": 24,
"startTime": "2023-01-01T01:01:00",
"startTime": "[if(equals(reference('timeZones').outputs.Timezone.value, 'UTC'), '2023-01-01T01:01:00Z', '2023-01-01T01:01:00')]",
Comment thread
flanakin marked this conversation as resolved.
"timeZone": "[reference('timeZones').outputs.Timezone.value]"
}
}
Expand Down Expand Up @@ -14064,7 +14064,7 @@
"recurrence": {
"frequency": "Month",
"interval": 1,
"startTime": "2023-01-05T01:11:00",
"startTime": "[if(equals(reference('timeZones').outputs.Timezone.value, 'UTC'), '2023-01-05T01:11:00Z', '2023-01-05T01:11:00')]",
"timeZone": "[reference('timeZones').outputs.Timezone.value]",
"schedule": {
"monthDays": [
Expand Down Expand Up @@ -23925,7 +23925,7 @@
"recurrence": {
"frequency": "Hour",
"interval": 24,
"startTime": "2023-01-01T01:01:00",
"startTime": "[if(equals(reference('timeZones').outputs.Timezone.value, 'UTC'), '2023-01-01T01:01:00Z', '2023-01-01T01:01:00')]",
"timeZone": "[reference('timeZones').outputs.Timezone.value]"
}
}
Expand Down
60 changes: 60 additions & 0 deletions src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
(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.

Separately, regression coverage for the ADF trigger startTime Z-suffix fix (issue #2157, PR #2291):
Data Factory requires a trailing 'Z' on a trigger's startTime whenever timeZone resolves to the 'UTC'
fallback; without it, activation fails with the same InvalidWorkflowTriggerRecurrence error. The three
affected triggers in ManagedExports/app.bicep and IngestionQueries/app.bicep make the 'Z' conditional
on the resolved timeZone, and finops-hub-14.0.json carries the same fix compiled to an ARM if(equals(...))
expression. These tests pin that pairing so it cannot silently regress. finops-hub-latest.json and
finops-hub-preview.json are intentionally not covered here (see the #2291 review discussion) - they
still ship the pre-fix, unconditional startTime and are expected to pick up the fix when regenerated
at the next release.
#>

Describe 'HubsAdfTriggerTimeZones' {
Expand Down Expand Up @@ -70,6 +80,38 @@ Describe 'HubsAdfTriggerTimeZones' {
}
}
}

# Extract every trigger startTime from the two src files fixed by #2291, keyed by the raw line
# so a failure points straight at the offending statement instead of just a file name.
$startTimeSrcFiles = @(
'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep'
'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep'
)
$startTimeSrcLines = @()
foreach ($relativePath in $startTimeSrcFiles)
{
foreach ($line in (Get-Content -Path (Join-Path $repoRoot $relativePath)))
{
if ($line -match '^\s*startTime:')
{
$startTimeSrcLines += @{ File = $relativePath; Line = $line.Trim() }
}
}
}

# Same pairing, compiled to the ARM if(equals(...)) form, in the one shipped deploy artifact
# patched alongside the src fix (finops-hub-14.0.json). finops-hub-latest.json and
# finops-hub-preview.json are excluded - they still ship the pre-fix startTime and pick up the
# fix at the next release regen.
$startTimeDeployFile = 'finops-hub-14.0.json'
$startTimeDeployLines = @()
foreach ($line in (Get-Content -Path (Join-Path $repoRoot "docs/deploy/$startTimeDeployFile")))
{
if ($line -match '"startTime":')
{
$startTimeDeployLines += @{ File = $startTimeDeployFile; Line = $line.Trim() }
}
}
}

BeforeAll {
Expand Down Expand Up @@ -142,4 +184,22 @@ Describe 'HubsAdfTriggerTimeZones' {
$bicepContent | Should -Match "\?\?\s*'UTC'"
Comment thread
flanakin marked this conversation as resolved.
}
}

Context 'startTime UTC suffix' {
Comment thread
flanakin marked this conversation as resolved.
It 'Pairs a Z-suffixed and bare startTime around the UTC fallback in <File>: <Line>' -TestCases $startTimeSrcLines {
# Data Factory requires the trailing 'Z' only when timeZone resolves to the 'UTC' fallback;
# a bare 'Z' or an unconditional literal would silently reintroduce #2157 or change scheduling
# behavior for every mapped, non-UTC region.
$matched = $Line -match "^startTime:\s*timeZones\.outputs\.Timezone == 'UTC' \? '([0-9T:-]+)Z' : '([0-9T:-]+)'$"
$matched | Should -BeTrue -Because "startTime should be conditional on the UTC fallback, got: $Line"
$Matches[1] | Should -BeExactly $Matches[2] -Because 'the UTC and non-UTC branches must use the same timestamp, differing only by the trailing Z'
}

It 'Pairs a Z-suffixed and bare startTime around the UTC fallback in <File>: <Line>' -TestCases $startTimeDeployLines {
# Same invariant, compiled to the ARM if(equals(...)) form shipped in the patched deploy artifact.
$matched = $Line -match "^`"startTime`":\s*`"\[if\(equals\(reference\('timeZones'\)\.outputs\.Timezone\.value, 'UTC'\), '([0-9T:-]+)Z', '([0-9T:-]+)'\)\]`",?$"
$matched | Should -BeTrue -Because "startTime should be conditional on the UTC fallback, got: $Line"
$Matches[1] | Should -BeExactly $Matches[2] -Because 'the UTC and non-UTC branches must use the same timestamp, differing only by the trailing Z'
}
}
}
Comment thread
flanakin marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
recurrence: {
frequency: 'Hour'
interval: 24
startTime: '2023-01-01T01:01:00'
// Data Factory requires a trailing 'Z' on startTime when timeZone resolves to 'UTC' (the
// fallback for regions not in timeZones.bicep's map); a missing 'Z' fails trigger activation
// with InvalidWorkflowTriggerRecurrence. Mapped, non-UTC regions must keep the no-'Z' format
// so they continue scheduling on local wall-clock time.
startTime: timeZones.outputs.Timezone == 'UTC' ? '2023-01-01T01:01:00Z' : '2023-01-01T01:01:00'
Comment thread
flanakin marked this conversation as resolved.
Comment thread
flanakin marked this conversation as resolved.
timeZone: timeZones.outputs.Timezone
}
}
Expand All @@ -138,7 +142,8 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
recurrence: {
frequency: 'Month'
interval: 1
startTime: '2023-01-05T01:11:00'
// See trigger_DailySchedule above for why the 'Z' suffix is conditional on the UTC fallback.
startTime: timeZones.outputs.Timezone == 'UTC' ? '2023-01-05T01:11:00Z' : '2023-01-05T01:11:00'
timeZone: timeZones.outputs.Timezone
schedule: {
monthDays: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ resource trigger_DailySchedule 'Microsoft.DataFactory/factories/triggers@2018-06
recurrence: {
frequency: 'Hour'
interval: 24
startTime: '2023-01-01T01:01:00'
// Data Factory requires a trailing 'Z' on startTime when timeZone resolves to 'UTC' (the
// fallback for regions not in timeZones.bicep's map); a missing 'Z' fails trigger activation
// with InvalidWorkflowTriggerRecurrence. Mapped, non-UTC regions must keep the no-'Z' format
// so they continue scheduling on local wall-clock time.
startTime: timeZones.outputs.Timezone == 'UTC' ? '2023-01-01T01:01:00Z' : '2023-01-01T01:01:00'
timeZone: timeZones.outputs.Timezone
}
}
Expand Down
Loading