Skip to content
2 changes: 2 additions & 0 deletions docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ The following section lists features and enhancements that are currently in deve

### [FinOps hubs](hubs/finops-hubs-overview.md) v14

- **Changed**
- Added typed metadata contracts between hub apps to formalize dependency management and enable compile-time verification of inter-app interfaces.
- **Fixed**
- Fixed Init-DataFactory deployment script failing when an Event Grid subscription is already provisioning by checking subscription status before attempting subscribe/unsubscribe and polling separately for completion ([#1996](https://github.com/microsoft/finops-toolkit/issues/1996)).

Expand Down
18 changes: 18 additions & 0 deletions src/scripts/Build-Toolkit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,24 @@ $templates | ForEach-Object {
}
}

# Replace $$ftkver$$ in all Bicep app.bicep files (for metadata version and URLs)
Write-Verbose " Replacing version placeholders in app.bicep files..."
$hubAppFiles = Get-ChildItem "$destDir" -Include 'app.bicep' -Recurse -Force
$replacedCount = 0
$hubAppFiles | ForEach-Object {
$content = Get-Content $_.FullName -Raw
if ($content -match '\$\$ftkver\$\$')
{
Write-Verbose " Replacing version in: $($_.FullName.Replace($destDir, ''))"
$content -replace '\$\$ftkver\$\$', $ver | Out-File $_.FullName -NoNewline
$replacedCount++
}
}
if ($replacedCount -gt 0)
{
Write-Verbose " Replaced version placeholder in $replacedCount file(s)"
}

# Build main.bicep, if applicable
if (Test-Path "$srcDir/main.bicep")
{
Expand Down
29 changes: 26 additions & 3 deletions src/scripts/Package-Toolkit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ function Copy-TemplateFiles()
}
}

$zip = if ($unversionedZip) {
$zip = if ($unversionedZip)
{
Join-Path (Get-Item $relDir) "$templateName.zip"
} else {
}
else
{
Join-Path (Get-Item $relDir) "$templateName-$tag.zip"
}

Expand Down Expand Up @@ -152,7 +155,27 @@ function Copy-TemplateFiles()
& "$PSScriptRoot/New-Directory" $targetDir

# Copy files and directories
$packageManifest.deployment.Files | ForEach-Object { Copy-Item "$srcPath/$($_.source)" "$targetDir/$($_.destination)" -Force }
$packageManifest.deployment.Files | ForEach-Object {
$destPath = $_.destination
$srcFolder = "$($srcPath.FullName)/$($_.sourceFolder)/".Replace("//", "/")
if (-not (Test-Path $srcFolder))
{
throw "Package manifest references source folder '$($_.sourceFolder)' that does not exist: $srcFolder"
}
Get-ChildItem $srcFolder -Include $_.source -Recurse:$_.recurse | ForEach-Object {
if ($destPath -eq '*')
{
$relativeDest = "$targetDir/$($_.FullName.Replace($srcFolder, ''))"
$destDir = Split-Path $relativeDest -Parent
if ($destDir) { & "$PSScriptRoot/New-Directory" $destDir }
Copy-Item $_ $relativeDest -Force
}
else
{
Copy-Item $_ "$targetDir/$destPath" -Force
}
}
}
$packageManifest.deployment.Directories | ForEach-Object {
& "$PSScriptRoot/New-Directory" "$targetDir/$($_.destination)"
Get-ChildItem "$srcPath/$($_.source)" | Copy-Item -Destination "$targetDir/$($_.destination)" -Recurse -Force
Expand Down
1 change: 1 addition & 0 deletions src/templates/finops-hub/bicepconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
}
},
"experimentalFeaturesEnabled": {
"userDefinedConstraints": true,
"userDefinedFunctions": true
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { finOpsToolkitVersion, HubAppProperties } from '../../fx/hub-types.bicep'
import { finOpsToolkitVersion, HubAppProperties, isSupportedVersion } from '../../fx/hub-types.bicep'
import { AppMetadata as CoreMetadata } from '../../Microsoft.FinOpsHubs/Core/metadata.bicep'
import { AppMetadata as ExportsMetadata } from './metadata.bicep'

metadata hubApp = {
id: 'Microsoft.CostManagement.Exports'
version: '$$ftkver$$'
dependencies: [
'Microsoft.FinOpsHubs.Core'
]
metadata: 'https://microsoft.github.io/finops-toolkit/deploy/$$ftkver$$/Microsoft.CostManagement/Exports/metadata.bicep'
}


//==============================================================================
Expand All @@ -11,18 +22,17 @@ import { finOpsToolkitVersion, HubAppProperties } from '../../fx/hub-types.bicep
@description('Required. FinOps hub app getting deployed.')
param app HubAppProperties

@description('Required. Metadata describing shared resources from the Core app. Must be v13 or higher.')
@validate(x => isSupportedVersion(x.version, '13.0', ''), 'Cost Management Exports requires FinOps hubs version 13.0 or higher.')
param core CoreMetadata


//==============================================================================
// Variables
//==============================================================================

var CONFIG = 'config'
var INGESTION = 'ingestion'
var MSEXPORTS = 'msexports'

// Separator used to separate ingestion ID from file name for ingested files
var ingestionIdFileNameSeparator = '__'


//==============================================================================
// Resources
Expand Down Expand Up @@ -53,7 +63,7 @@ module schemaFiles '../../fx/hub-storage.bicep' = {
]
params: {
app: app
container: 'config'
container: core.containers.config
files: {
// cSpell:ignore actualcost, amortizedcost, focuscost, pricesheet, reservationdetails, reservationrecommendations, reservationtransactions
'schemas/actualcost_c360-2025-04.json': loadTextContent('./schemas/actualcost_c360-2025-04.json')
Expand Down Expand Up @@ -102,23 +112,23 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
}

resource dataset_config 'datasets' existing = {
name: CONFIG
name: core.datasets.config
}

resource dataset_ingestion 'datasets' existing = {
name: INGESTION
name: core.datasets.ingestion
}

resource dataset_ingestion_files 'datasets' existing = {
name: '${INGESTION}_files'
name: core.datasets.ingestionFiles
}

resource dataset_ingestion_manifest 'datasets' existing = {
name: 'ingestion_manifest'
name: core.datasets.ingestionManifest
}

resource dataset_msexports_manifest 'datasets' = {
name: 'msexports_manifest'
name: '${MSEXPORTS}_manifest'
properties: {
parameters: {
fileName: {
Expand Down Expand Up @@ -152,7 +162,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
}

resource dataset_msexports 'datasets' = {
name: replace('${MSEXPORTS}', '-', '_')
name: replace(MSEXPORTS, '-', '_')
properties: {
parameters: {
blobPath: {
Expand Down Expand Up @@ -1003,7 +1013,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
parameters: {
fileName: 'manifest.json'
folderPath: {
value: '@concat(\'${INGESTION}/\', variables(\'destinationFolder\'))'
value: '@concat(\'${core.containers.ingestion}/\', variables(\'destinationFolder\'))'
type: 'Expression'
}
}
Expand Down Expand Up @@ -1059,7 +1069,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
// Triggered by msexports_ExecuteETL
//---------------------------------------------------------------------------
resource pipeline_ToIngestion 'pipelines' = {
name: '${MSEXPORTS}_ETL_${INGESTION}'
name: '${MSEXPORTS}_ETL_${core.containers.ingestion}'
properties: {
activities: [
{ // Get Existing Parquet Files
Expand Down Expand Up @@ -1115,7 +1125,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
}
condition: {
// cSpell:ignore endswith
value: '@and(endswith(item().name, \'.parquet\'), not(startswith(item().name, concat(pipeline().parameters.ingestionId, \'${ingestionIdFileNameSeparator}\'))))'
value: '@and(endswith(item().name, \'.parquet\'), not(startswith(item().name, concat(pipeline().parameters.ingestionId, \'${core.ingestionIdFileNameSeparator}\'))))'
type: 'Expression'
}
}
Expand Down Expand Up @@ -1153,7 +1163,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
value: '@toLower(pipeline().parameters.schemaFile)'
type: 'Expression'
}
folderPath: '${CONFIG}/schemas'
folderPath: '${core.containers.config}/schemas'
}
}
}
Expand Down Expand Up @@ -1274,14 +1284,14 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
typeProperties: {
variableName: 'destinationPath'
value: {
value: '@concat(pipeline().parameters.destinationFolder, \'/\', pipeline().parameters.ingestionId, \'${ingestionIdFileNameSeparator}\', pipeline().parameters.destinationFile)'
value: '@concat(pipeline().parameters.destinationFolder, \'/\', pipeline().parameters.ingestionId, \'${core.ingestionIdFileNameSeparator}\', pipeline().parameters.destinationFile)'
type: 'Expression'
}
}
}
{ // Convert to Parquet
name: 'Convert to Parquet'
description: 'Convert CSV to parquet and move the file to the ${INGESTION} container.'
description: 'Convert CSV to parquet and move the file to the ${core.containers.ingestion} container.'
type: 'Switch'
dependsOn: [
{
Expand Down Expand Up @@ -1582,7 +1592,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = {
type: 'DatasetReference'
parameters: {
fileName: 'settings.json'
folderPath: CONFIG
folderPath: core.containers.config
}
}
}
Expand Down Expand Up @@ -1711,8 +1721,20 @@ module trigger_ExportManifestAdded '../../fx/hub-eventTrigger.bicep' = {
@description('Properties of the hub app.')
output app HubAppProperties = app

@description('Name of the container used for Cost Management exports.')
output exportContainer string = exportContainer.outputs.containerName

@description('Number of schema files uploaded.')
output schemaFilesUploaded int = schemaFiles.outputs.filesUploaded

@description('Metadata describing resources created by the Cost Management Exports app.')
output metadata ExportsMetadata = {
id: 'Microsoft.CostManagement.Exports'
version: finOpsToolkitVersion
containers: {
msexports: exportContainer.outputs.containerName
}
datasets: {
msexportsManifest: dataFactory::dataset_msexports_manifest.name
msexports: dataFactory::dataset_msexports.name
msexportsGzip: dataFactory::dataset_msexports_gzip.name
msexportsParquet: dataFactory::dataset_msexports_parquet.name
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//==============================================================================
// App metadata definition
//==============================================================================

@export()
@description('Metadata for resources created by the Cost Management Exports app.')
type AppMetadata = {
@description('Fully-qualified app identifier.')
id: string
@description('App version.')
version: string
@description('Storage container names.')
containers: {
@description('Container for raw Cost Management export files.')
msexports: string
}
@description('Data Factory dataset names.')
datasets: {
@description('JSON dataset for export manifest files.')
msexportsManifest: string
@description('CSV dataset for raw export files.')
msexports: string
@description('Gzip dataset for compressed export files.')
msexportsGzip: string
@description('Parquet dataset for converted export files.')
msexportsParquet: string
}
}
Loading