diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 03550d90b..8e408ef9e 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -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)). diff --git a/src/scripts/Build-Toolkit.ps1 b/src/scripts/Build-Toolkit.ps1 index a56d1f46d..c8bbdbb4b 100644 --- a/src/scripts/Build-Toolkit.ps1 +++ b/src/scripts/Build-Toolkit.ps1 @@ -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") { diff --git a/src/scripts/Package-Toolkit.ps1 b/src/scripts/Package-Toolkit.ps1 index 65108b772..5abe064ff 100644 --- a/src/scripts/Package-Toolkit.ps1 +++ b/src/scripts/Package-Toolkit.ps1 @@ -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" } @@ -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 diff --git a/src/templates/finops-hub/bicepconfig.json b/src/templates/finops-hub/bicepconfig.json index 58ec1a044..a360f1853 100644 --- a/src/templates/finops-hub/bicepconfig.json +++ b/src/templates/finops-hub/bicepconfig.json @@ -11,6 +11,7 @@ } }, "experimentalFeaturesEnabled": { + "userDefinedConstraints": true, "userDefinedFunctions": true } } diff --git a/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/app.bicep b/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/app.bicep index a3dae7ebf..c36373515 100644 --- a/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/app.bicep @@ -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' +} //============================================================================== @@ -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 @@ -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') @@ -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: { @@ -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: { @@ -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' } } @@ -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 @@ -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' } } @@ -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' } } } @@ -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: [ { @@ -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 } } } @@ -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 + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/metadata.bicep b/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/metadata.bicep new file mode 100644 index 000000000..c822a03f9 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/metadata.bicep @@ -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 + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep b/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep index fccc50c6b..950ba70d0 100644 --- a/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep @@ -1,7 +1,20 @@ // 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 '../Exports/metadata.bicep' +import { AppMetadata as ManagedExportsMetadata } from './metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.CostManagement.ManagedExports' + version: '$$ftkver$$' + dependencies: [ + 'Microsoft.FinOpsHubs.Core' + 'Microsoft.CostManagement.Exports' + ] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/$$ftkver$$/Microsoft.CostManagement/ManagedExports/metadata.bicep' +} //============================================================================== @@ -11,14 +24,19 @@ 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 Managed Exports requires FinOps hubs version 13.0 or higher.') +param core CoreMetadata + +@description('Required. Metadata describing shared resources from the Exports app. Must be v13 or higher.') +@validate(x => isSupportedVersion(x.version, '13.0', ''), 'Cost Management Managed Exports requires Cost Management Exports version 13.0 or higher.') +param exports ExportsMetadata + //============================================================================== // Variables //============================================================================== -var CONFIG = 'config' -var MSEXPORTS = 'msexports' - var exportsApiVersion = '2023-07-01-preview' var exportDataVersions = { focuscost: '1.2-preview' @@ -72,11 +90,11 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { name: app.dataFactory resource dataset_config 'datasets' existing = { - name: CONFIG + name: core.datasets.config } resource trigger_DailySchedule 'triggers' = { - name: '${CONFIG}_DailySchedule' + name: '${core.datasets.config}_DailySchedule' properties: { pipelines: [ { @@ -102,7 +120,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } resource trigger_MonthlySchedule 'triggers' = { - name: '${CONFIG}_MonthlySchedule' + name: '${core.datasets.config}_MonthlySchedule' properties: { pipelines: [ { @@ -138,7 +156,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // config_StartBackfillProcess pipeline //---------------------------------------------------------------------------- resource pipeline_StartBackfillProcess 'pipelines' = { - name: '${CONFIG}_StartBackfillProcess' + name: '${core.datasets.config}_StartBackfillProcess' properties: { activities: [ { // Get Config @@ -376,7 +394,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } folderPath: { type: 'String' - defaultValue: CONFIG + defaultValue: core.containers.config } endDate: { type: 'String' @@ -399,7 +417,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Triggered by config_StartBackfillProcess pipeline //---------------------------------------------------------------------------- resource pipeline_RunBackfillJob 'pipelines' = { - name: '${CONFIG}_RunBackfillJob' + name: '${core.datasets.config}_RunBackfillJob' properties: { activities: [ { // Get Config @@ -632,7 +650,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } folderPath: { type: 'String' - defaultValue: CONFIG + defaultValue: core.containers.config } scopesArray: { type: 'Array' @@ -646,7 +664,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Triggered by config_DailySchedule/MonthlySchedule triggers //---------------------------------------------------------------------------- resource pipeline_StartExportProcess 'pipelines' = { - name: '${CONFIG}_StartExportProcess' + name: '${core.datasets.config}_StartExportProcess' properties: { activities: [ { // Get Config @@ -865,7 +883,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } folderPath: { type: 'String' - defaultValue: CONFIG + defaultValue: core.containers.config } finOpsHub: { type: 'String' @@ -887,7 +905,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Triggered by pipeline_StartExportProcess pipeline //---------------------------------------------------------------------------- resource pipeline_RunExportJobs 'pipelines' = { - name: '${CONFIG}_RunExportJobs' + name: '${core.datasets.config}_RunExportJobs' dependsOn: [ dataset_config ] @@ -983,7 +1001,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Triggered by config_SettingsUpdated trigger //---------------------------------------------------------------------------- resource pipeline_ConfigureExports 'pipelines' = { - name: '${CONFIG}_ConfigureExports' + name: '${core.datasets.config}_ConfigureExports' properties: { activities: [ { // Get Config @@ -1181,7 +1199,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1221,7 +1239,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1261,7 +1279,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'Pricesheet', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'Pricesheet', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1338,7 +1356,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'ReservationDetails', false, 'CSV', 'None', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'ReservationDetails', false, 'CSV', 'None', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1378,7 +1396,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'ReservationTransactions', false, 'CSV', 'None', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'ReservationTransactions', false, 'CSV', 'None', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1418,7 +1436,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'ReservationRecommendations', false, 'CSV', 'None', 'true', 'CreateNewReport', 'Shared', 'Last30Days', 'VirtualMachines') + value: getExportBodyV2(exports.containers.msexports, 'ReservationRecommendations', false, 'CSV', 'None', 'true', 'CreateNewReport', 'Shared', 'Last30Days', 'VirtualMachines') type: 'Expression' } headers: { @@ -1459,7 +1477,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1499,7 +1517,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1540,7 +1558,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', false, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1580,7 +1598,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } method: 'PUT' body: { - value: getExportBodyV2(MSEXPORTS, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') + value: getExportBodyV2(exports.containers.msexports, 'FocusCost', true, 'Parquet', 'Snappy', 'true', 'CreateNewReport', '', '', '') type: 'Expression' } headers: { @@ -1670,7 +1688,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } folderPath: { type: 'String' - defaultValue: CONFIG + defaultValue: core.containers.config } } } @@ -1689,14 +1707,14 @@ module trigger_SettingsUpdated '../../fx/hub-eventTrigger.bicep' = { name: 'Microsoft.FinOpsHubs.Core_SettingsUpdatedTrigger' params: { dataFactoryName: dataFactory.name - triggerName: '${CONFIG}_SettingsUpdated' + triggerName: '${core.datasets.config}_SettingsUpdated' // TODO: Replace pipeline with event: 'Microsoft.FinOpsHubs.Core.SettingsUpdated' pipelineName: dataFactory::pipeline_ConfigureExports.name pipelineParameters: {} storageAccountName: app.storage - storageContainer: CONFIG + storageContainer: core.containers.config // TODO: Change this to startswith storagePathEndsWith: 'settings.json' } @@ -1707,4 +1725,13 @@ module trigger_SettingsUpdated '../../fx/hub-eventTrigger.bicep' = { // Outputs //============================================================================== -// None +@description('Metadata describing resources created by the Cost Management Managed Exports app.') +output metadata ManagedExportsMetadata = { + id: 'Microsoft.CostManagement.ManagedExports' + version: finOpsToolkitVersion + pipelines: { + configureExports: dataFactory::pipeline_ConfigureExports.name + startBackfillProcess: dataFactory::pipeline_StartBackfillProcess.name + startExportProcess: dataFactory::pipeline_StartExportProcess.name + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/metadata.bicep b/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/metadata.bicep new file mode 100644 index 000000000..e5796a345 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/metadata.bicep @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//============================================================================== +// App metadata definition +//============================================================================== + +@export() +@description('Metadata for resources created by the Cost Management Managed Exports app.') +type AppMetadata = { + @description('Fully-qualified app identifier.') + id: string + @description('App version.') + version: string + @description('Data Factory pipeline names for public API.') + pipelines: { + @description('Pipeline to start the backfill process.') + startBackfillProcess: string + @description('Pipeline to start the export process.') + startExportProcess: string + @description('Pipeline to configure Cost Management exports.') + configureExports: string + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep index 8b35ddec8..2247ba840 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep @@ -1,7 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices } from '../../fx/hub-types.bicep' +import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices, isSupportedVersion } from '../../fx/hub-types.bicep' +import { AppMetadata as CoreMetadata } from '../Core/metadata.bicep' +import { AppMetadata as AnalyticsMetadata } from './metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.FinOpsHubs.Analytics' + version: '$$ftkver$$' + dependencies: ['Microsoft.FinOpsHubs.Core'] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/$$ftkver$$/Microsoft.FinOpsHubs/Analytics/metadata.bicep' +} //============================================================================== @@ -11,6 +20,10 @@ import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices @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', ''), 'FinOps hubs Analytics requires FinOps hubs version 13.0 or higher.') +param core CoreMetadata + @description('Optional. Name of the Azure Data Explorer cluster to use for advanced analytics. If empty, Azure Data Explorer will not be deployed. Required to use with Power BI if you have more than $2-5M/mo in costs being monitored. Default: "" (do not use).') @maxLength(22) param clusterName string = '' @@ -117,12 +130,9 @@ param rawRetentionInDays int // Variables //============================================================================== -var CONFIG = 'config' var HUB_DATA_EXPLORER = 'hubDataExplorer' var HUB_DB = 'Hub' -var INGESTION = 'ingestion' var INGESTION_DB = 'Ingestion' -var INGESTION_ID_SEPARATOR = '__' var ftkGitTag = loadTextContent('../../fx/ftktag.txt') // cSpell:ignore ftktag var ftkReleaseUri = indexOf(finOpsToolkitVersion, '-dev') != -1 @@ -672,16 +682,16 @@ module trigger_IngestionManifestAdded '../../fx/hub-eventTrigger.bicep' = { name: 'Microsoft.FinOpsHubs.Core_IngestionManifestAddedTrigger' params: { dataFactoryName: dataFactory.name - triggerName: '${INGESTION}_ManifestAdded' + triggerName: '${core.containers.ingestion}_ManifestAdded' // TODO: Replace pipeline with event: 'Microsoft.FinOpsHubs.Core.IngestionManifestAdded' pipelineName: pipeline_ExecuteIngestionETL.name pipelineParameters: { folderPath: '@triggerBody().folderPath' } - + storageAccountName: app.storage - storageContainer: INGESTION + storageContainer: core.containers.ingestion storagePathEndsWith: 'manifest.json' } } @@ -691,7 +701,7 @@ module trigger_IngestionManifestAdded '../../fx/hub-eventTrigger.bicep' = { //------------------------------------------------------------------------------ @description('Initializes the hub instance based on the configuration settings.') resource pipeline_InitializeHub 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { - name: '${CONFIG}_InitializeHub' + name: '${core.datasets.config}_InitializeHub' parent: dataFactory properties: { activities: [ @@ -720,7 +730,7 @@ resource pipeline_InitializeHub 'Microsoft.DataFactory/factories/pipelines@2018- } } dataset: { - referenceName: CONFIG + referenceName: core.datasets.config type: 'DatasetReference' } } @@ -1173,7 +1183,7 @@ resource pipeline_InitializeHub 'Microsoft.DataFactory/factories/pipelines@2018- //------------------------------------------------------------------------------ @description('Ingests parquet data into an Azure Data Explorer cluster.') resource pipeline_ToDataExplorer 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = if (useAzure || useFabric) { - name: '${INGESTION}_ETL_dataExplorer' + name: '${core.containers.ingestion}_ETL_dataExplorer' parent: dataFactory properties: { activities: [ @@ -1202,11 +1212,11 @@ resource pipeline_ToDataExplorer 'Microsoft.DataFactory/factories/pipelines@2018 } } dataset: { - referenceName: CONFIG + referenceName: core.datasets.config type: 'DatasetReference' parameters: { fileName: 'settings.json' - folderPath: CONFIG + folderPath: core.containers.config } } } @@ -1374,7 +1384,7 @@ resource pipeline_ToDataExplorer 'Microsoft.DataFactory/factories/pipelines@2018 typeProperties: { command: { // cSpell:ignore abfss, toscalar - value: '@concat(\'.ingest into table \', pipeline().parameters.table, \' ("abfss://${INGESTION}@${app.storage}.dfs.${environment().suffixes.storage}/\', pipeline().parameters.folderPath, \'/\', pipeline().parameters.fileName, \';${useFabric ? 'impersonate' : 'managed_identity=system'}") with (format="parquet", ingestionMappingReference="\', pipeline().parameters.table, \'_mapping", tags="[\\"drop-by:\', pipeline().parameters.ingestionId, \'\\", \\"drop-by:\', pipeline().parameters.folderPath, \'/\', pipeline().parameters.originalFileName, \'\\", \\"drop-by:ftk-version-${finOpsToolkitVersion}\\"]"); print Success = assert(iff(toscalar($command_results | project-keep HasErrors) == false, true, false), "Ingestion Failed")\')' + value: '@concat(\'.ingest into table \', pipeline().parameters.table, \' ("abfss://${core.containers.ingestion}@${app.storage}.dfs.${environment().suffixes.storage}/\', pipeline().parameters.folderPath, \'/\', pipeline().parameters.fileName, \';${useFabric ? 'impersonate' : 'managed_identity=system'}") with (format="parquet", ingestionMappingReference="\', pipeline().parameters.table, \'_mapping", tags="[\\"drop-by:\', pipeline().parameters.ingestionId, \'\\", \\"drop-by:\', pipeline().parameters.folderPath, \'/\', pipeline().parameters.originalFileName, \'\\", \\"drop-by:ftk-version-${finOpsToolkitVersion}\\"]"); print Success = assert(iff(toscalar($command_results | project-keep HasErrors) == false, true, false), "Ingestion Failed")\')' type: 'Expression' } commandTimeout: '01:00:00' @@ -1615,7 +1625,7 @@ resource pipeline_ToDataExplorer 'Microsoft.DataFactory/factories/pipelines@2018 //------------------------------------------------------------------------------ @description('Queues the ingestion_ETL_dataExplorer pipeline to account for Data Factory pipeline trigger limits.') resource pipeline_ExecuteIngestionETL 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = if (useAzure || useFabric) { - name: '${INGESTION}_ExecuteETL' + name: '${core.containers.ingestion}_ExecuteETL' parent: dataFactory properties: { concurrency: 1 @@ -1793,11 +1803,11 @@ resource pipeline_ExecuteIngestionETL 'Microsoft.DataFactory/factories/pipelines type: 'Expression' } originalFileName: { - value: '@last(array(split(item().name, \'${INGESTION_ID_SEPARATOR}\')))' + value: '@last(array(split(item().name, \'${core.ingestionIdFileNameSeparator}\')))' type: 'Expression' } ingestionId: { - value: '@concat(first(array(split(item().name, \'${INGESTION_ID_SEPARATOR}\'))), \'_\', variables(\'timestamp\'))' + value: '@concat(first(array(split(item().name, \'${core.ingestionIdFileNameSeparator}\'))), \'_\', variables(\'timestamp\'))' type: 'Expression' } table: { @@ -1890,26 +1900,33 @@ module runInitializationPipeline '../../fx/hub-initialize.bicep' = if (useAzure // Outputs //============================================================================== -@description('The resource ID of the cluster.') -#disable-next-line BCP318 // Null safety warning for conditional resource access -output clusterId string = useFabric ? '' : cluster.id - -@description('The ID of the cluster system assigned managed identity.') -#disable-next-line BCP318 // Null safety warning for conditional resource access -output principalId string = useFabric ? '' : cluster.identity.principalId - -@description('The name of the cluster.') -#disable-next-line BCP318 // Null safety warning for conditional resource access -output clusterName string = useFabric ? '' : cluster.name - -@description('The URI of the cluster.') -output clusterUri string = dataExplorerUri - -@description('The name of the database for data ingestion.') -output ingestionDbName string = INGESTION_DB // Don't use cluster DB reference since that won't work for Fabric - -@description('The name of the database for queries.') -output hubDbName string = HUB_DB // Don't use cluster DB reference since that won't work for Fabric - @description('Max ingestion capacity of the cluster.') output clusterIngestionCapacity int = dataExplorerIngestionCapacity + +@description('Metadata describing resources created by the Analytics app.') +output metadata AnalyticsMetadata = { + id: 'Microsoft.FinOpsHubs.Analytics' + version: finOpsToolkitVersion + cluster: { + #disable-next-line BCP318 // Null safety warning for conditional resource access + id: useFabric ? '' : cluster.id + #disable-next-line BCP318 // Null safety warning for conditional resource access + name: useFabric ? '' : cluster.name + uri: dataExplorerUri + #disable-next-line BCP318 // Null safety warning for conditional resource access + principalId: useFabric ? '' : cluster.identity.principalId + } + databases: { + // Don't use cluster DB references since they won't work for Fabric + ingestion: INGESTION_DB + hub: HUB_DB + } + linkedServices: { + hubDataExplorer: linkedService_dataExplorer.name + ftkRepo: linkedService_ftkRepo.name + } + datasets: { + hubDataExplorer: dataset_dataExplorer.name + ftkReleaseFile: dataset_ftkReleaseFile.name + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/metadata.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/metadata.bicep new file mode 100644 index 000000000..84c4c3392 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/metadata.bicep @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//============================================================================== +// App metadata definition +//============================================================================== + +@export() +@description('Metadata for resources created by the Analytics app.') +type AppMetadata = { + @description('Fully-qualified app identifier.') + id: string + @description('App version.') + version: string + @description('Data Explorer cluster or Fabric endpoint properties.') + cluster: { + @description('Resource ID of the cluster. Empty if using Fabric.') + id: string + @description('Name of the cluster. Empty if using Fabric.') + name: string + @description('URI of the cluster or Fabric query endpoint.') + uri: string + @description('Object ID of the cluster system-assigned managed identity. Empty if using Fabric.') + principalId: string + } + @description('Database names.') + databases: { + @description('Database used for data ingestion.') + ingestion: string + @description('Database used for queries.') + hub: string + } + @description('Data Factory linked service names.') + linkedServices: { + @description('Linked service for Azure Data Explorer or Microsoft Fabric.') + hubDataExplorer: string + @description('HTTP linked service for the FinOps toolkit GitHub repository.') + ftkRepo: string + } + @description('Data Factory dataset names.') + datasets: { + @description('Dataset for Azure Data Explorer or Microsoft Fabric.') + hubDataExplorer: string + @description('Dataset for FinOps toolkit release files from GitHub.') + ftkReleaseFile: string + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/app.bicep index 4e30b883f..989e5b83c 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/app.bicep @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { finOpsToolkitVersion, HubAppProperties } from '../../fx/hub-types.bicep' +import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices } from '../../fx/hub-types.bicep' +import { AppMetadata as CoreMetadata } from './metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.FinOpsHubs.Core' + version: '$$ftkver$$' + dependencies: [] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/$$ftkver$$/Microsoft.FinOpsHubs/Core/metadata.bicep' +} //============================================================================== @@ -35,6 +43,10 @@ param finalRetentionInMonths int = 13 var CONFIG = 'config' var INGESTION = 'ingestion' +var INGESTION_ID_SEPARATOR = '__' + +// Workaround for Bicep warning when using "ResourceId" in property names +var armEndpointPropertyName = 'aadResourceId' //============================================================================== @@ -133,7 +145,7 @@ module uploadSettings '../../fx/hub-deploymentScript.bicep' = { } { name: 'containerName' - value: 'config' + value: CONFIG } ] } @@ -238,7 +250,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } resource dataset_ingestion_manifest 'datasets' = { - name: 'ingestion_manifest' + name: '${INGESTION}_manifest' properties: { annotations: [] parameters: { @@ -274,6 +286,34 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } } +//------------------------------------------------------------------------------ +// Shared linked services +//------------------------------------------------------------------------------ + +// Azure Resource Manager REST linked service +// Shared by any app that needs to call ARM REST APIs (e.g., Azure Resource Graph, Resource Manager, etc.) +resource linkedService_arm 'Microsoft.DataFactory/factories/linkedservices@2018-06-01' = { + name: 'azurerm' + parent: dataFactory + properties: { + annotations: [] + parameters: {} + type: 'RestService' + typeProperties: union( + { + url: environment().resourceManager + authenticationType: 'ManagedServiceIdentity' + enableServerCertificateValidation: true + }, + { + // Workaround: When bicep sees "ResourceId" in the property name, it raises a warning + '${armEndpointPropertyName}': environment().resourceManager + } + ) + ...privateRoutingForLinkedServices(app.hub) + } +} + //============================================================================== // Outputs //============================================================================== @@ -292,8 +332,33 @@ output storageAccountName string = app.storage @description('URL to use when connecting custom Power BI reports to your data.') output storageUrlForPowerBI string = 'https://${app.storage}.dfs.${environment().suffixes.storage}/${INGESTION}' -@description('Object ID of the Data Factory managed identity. This will be needed when configuring managed exports.') -output principalId string = dataFactory.identity.principalId - @description('Name of the managed identity used to create and stop ADF triggers.') output triggerManagerIdentityName string = appRegistration.outputs.triggerManagerIdentityName + +@description('Metadata describing shared resources created by the Core app.') +output metadata CoreMetadata = { + id: 'Microsoft.FinOpsHubs.Core' + version: finOpsToolkitVersion + storageUrlForPowerBI: 'https://${app.storage}.dfs.${environment().suffixes.storage}/${INGESTION}' + principalId: dataFactory.identity.principalId + ingestionIdFileNameSeparator: INGESTION_ID_SEPARATOR + containers: { + config: configContainer.outputs.containerName + ingestion: ingestionContainer.outputs.containerName + } + datasets: { + config: dataFactory::dataset_config.name + ingestion: dataFactory::dataset_ingestion.name + ingestionFiles: dataFactory::dataset_ingestion_files.name + ingestionManifest: dataFactory::dataset_ingestion_manifest.name + } + linkedServices: { + azurerm: linkedService_arm.name + } + settings: { + container: CONFIG + folder: '' + file: dataFactory::dataset_config.properties.parameters.fileName.defaultValue + } +} + diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/metadata.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/metadata.bicep new file mode 100644 index 000000000..c776a414b --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/metadata.bicep @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//============================================================================== +// App metadata definition +//============================================================================== + +@export() +@description('Metadata for resources created by the Core app.') +type AppMetadata = { + @description('Fully-qualified app identifier.') + id: string + @description('App version.') + version: string + @description('URL to use when connecting Power BI reports to data.') + storageUrlForPowerBI: string + // TODO: Review whether identity properties should be in metadata or handled differently + @description('Object ID of the Data Factory managed identity. Needed when configuring managed exports.') + principalId: string + @description('Separator characters used between the ingestion ID and file name for ingested files. Used to identify uniqueness and clean up old files with old ingestion IDs.') + ingestionIdFileNameSeparator: string + @description('Storage container names.') + containers: { + @description('Configuration container for settings, queries, and schemas.') + config: string + @description('Ingestion container for normalized data.') + ingestion: string + } + @description('Data Factory dataset names.') + datasets: { + @description('JSON dataset for configuration files.') + config: string + @description('Parquet dataset for ingested data.') + ingestion: string + @description('Parquet dataset for listing ingested files.') + ingestionFiles: string + @description('JSON dataset for ingestion manifest files.') + ingestionManifest: string + } + @description('Data Factory linked service names.') + linkedServices: { + @description('REST linked service for Azure Resource Manager API calls.') + azurerm: string + } + @description('Metadata for the hub settings file.') + settings: { + @description('Container name for the hub settings file.') + container: string + @description('Folder path for the hub settings file within the container.') + folder: string + @description('File name of the hub settings file.') + file: string + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/RemoteHub/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/RemoteHub/app.bicep index 7c13eb207..27c22e938 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/RemoteHub/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/RemoteHub/app.bicep @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices } from '../../fx/hub-types.bicep' +import { finOpsToolkitVersion, HubAppProperties, privateRoutingForLinkedServices, isSupportedVersion } from '../../fx/hub-types.bicep' +import { AppMetadata as CoreMetadata } from '../Core/metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.FinOpsHubs.RemoteHub' + version: '$$ftkver$$' + dependencies: ['Microsoft.FinOpsHubs.Core'] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/$$ftkver$$/Microsoft.FinOpsHubs/RemoteHub/metadata.bicep' +} //============================================================================== @@ -18,8 +26,9 @@ param remoteStorageKey string @description('Required. Remote storage account for ingestion dataset.') param remoteHubStorageUri string -@description('Optional. Name of the ingestion container. Default: ingestion.') -param ingestionContainerName string = 'ingestion' +@description('Required. Metadata describing shared resources from the Core app. Must be v13 or higher.') +@validate(x => isSupportedVersion(x.version, '13.0', ''), 'Remote hubs require FinOps hubs version 13.0 or higher.') +param core CoreMetadata //============================================================================== @@ -96,7 +105,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Replace the ingestion dataset resource dataset_ingestion 'datasets' = { - name: ingestionContainerName + name: core.datasets.ingestion properties: { annotations: [] parameters: { @@ -112,7 +121,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { value: '@{dataset().blobPath}' type: 'Expression' } - fileSystem: ingestionContainerName + fileSystem: core.containers.ingestion } } linkedServiceName: { @@ -125,7 +134,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Replace the ingestion_files dataset resource dataset_ingestion_files 'datasets' = { - name: '${ingestionContainerName}_files' + name: core.datasets.ingestionFiles properties: { annotations: [] parameters: { @@ -137,7 +146,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { typeProperties: { location: { type: 'AzureBlobFSLocation' - fileSystem: ingestionContainerName + fileSystem: core.containers.ingestion folderPath: { value: '@dataset().folderPath' type: 'Expression' @@ -154,7 +163,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { // Replace the ingestion_manifest dataset to write manifests to remote hub resource dataset_ingestion_manifest 'datasets' = { - name: 'ingestion_manifest' + name: core.datasets.ingestionManifest properties: { annotations: [] parameters: { @@ -164,7 +173,7 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { } folderPath: { type: 'String' - defaultValue: ingestionContainerName + defaultValue: core.containers.ingestion } } type: 'Json' @@ -194,6 +203,3 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { //============================================================================== // Outputs //============================================================================== - -@description('Name of the Key Vault instance.') -output keyVaultName string = app.keyVault diff --git a/src/templates/finops-hub/modules/fx/hub-types.bicep b/src/templates/finops-hub/modules/fx/hub-types.bicep index 0c4eb84be..2cb8e050e 100644 --- a/src/templates/finops-hub/modules/fx/hub-types.bicep +++ b/src/templates/finops-hub/modules/fx/hub-types.bicep @@ -160,6 +160,13 @@ var finOpsToolkitVersion = loadTextContent('ftkver.txt') // cSpell:ignore ftkve func safeStorageName(name string) string => replace(replace(toLower(name), '-', ''), '_', '') +@description('Converts a version string (e.g., "13.0", "13.1", "14.0-dev") to a comparable integer using major * 1000 + minor. Prerelease suffixes (e.g., "-dev") and patch versions are ignored.') +func versionToNumber(version string) int => int(split(split(version, '-')[0], '.')[0]) * 1000 + (length(split(split(version, '-')[0], '.')) > 1 ? int(split(split(version, '-')[0], '.')[1]) : 0) + +@export() +@description('Checks if a version string falls within the specified range. Use empty string to skip a bound. Example: isSupportedVersion(\'13.1\', \'13.0\', \'\') checks >= 13.0 with no upper limit.') +func isSupportedVersion(version string, minVersion string, maxVersion string) bool => (empty(minVersion) || versionToNumber(version) >= versionToNumber(minVersion)) && (empty(maxVersion) || versionToNumber(version) <= versionToNumber(maxVersion)) + func idName(name string, resourceType string) IdNameObject => { id: resourceId(resourceType, name) name: name diff --git a/src/templates/finops-hub/modules/hub.bicep b/src/templates/finops-hub/modules/hub.bicep index 4cfa43646..af343e3ad 100644 --- a/src/templates/finops-hub/modules/hub.bicep +++ b/src/templates/finops-hub/modules/hub.bicep @@ -262,21 +262,18 @@ module core 'Microsoft.FinOpsHubs/Core/app.bicep' = { module cmExports 'Microsoft.CostManagement/Exports/app.bicep' = { name: 'Microsoft.CostManagement.Exports' - dependsOn: [ - core - ] params: { app: newApp(hub, 'Microsoft.CostManagement', 'Exports') + core: core.outputs.metadata } } module cmManagedExports 'Microsoft.CostManagement/ManagedExports/app.bicep' = if (enableManagedExports) { name: 'Microsoft.CostManagement.ManagedExports' - dependsOn: [ - cmExports - ] params: { app: newApp(hub, 'Microsoft.CostManagement', 'ManagedExports') + core: core.outputs.metadata + exports: cmExports.outputs.metadata } } @@ -287,15 +284,13 @@ module cmManagedExports 'Microsoft.CostManagement/ManagedExports/app.bicep' = if module analytics 'Microsoft.FinOpsHubs/Analytics/app.bicep' = if (useFabric || useAzureDataExplorer) { name: 'Microsoft.FinOpsHubs.Analytics' dependsOn: hub.options.privateRouting ? [ - core // When private endpoints are enabled, we need to explicitly block on anything that uses deployment scripts to guarantee only one deployment script runs at a time cmExports deleteOldResources - ] : [ - core - ] + ] : [] params: { app: newApp(hub, 'Microsoft.FinOpsHubs', 'Analytics') + core: core.outputs.metadata fabricQueryUri: fabricQueryUri fabricCapacityUnits: fabricCapacityUnits clusterName: dataExplorerName @@ -312,11 +307,9 @@ module analytics 'Microsoft.FinOpsHubs/Analytics/app.bicep' = if (useFabric || u module remoteHub 'Microsoft.FinOpsHubs/RemoteHub/app.bicep' = if (!empty(remoteHubStorageKey)) { name: 'Microsoft.FinOpsHubs.RemoteHub' - dependsOn: [ - core - ] params: { app: newApp(hub, 'Microsoft.FinOpsHubs', 'RemoteHub') + core: core.outputs.metadata remoteStorageKey: remoteHubStorageKey remoteHubStorageUri: remoteHubStorageUri } @@ -390,26 +383,26 @@ output storageAccountId string = resourceId('Microsoft.Storage/storageAccounts', output storageAccountName string = core.outputs.storageAccountName @description('URL to use when connecting custom Power BI reports to your data.') -output storageUrlForPowerBI string = core.outputs.storageUrlForPowerBI +output storageUrlForPowerBI string = core.outputs.metadata.storageUrlForPowerBI @description('The resource ID of the Data Explorer cluster.') #disable-next-line BCP318 // Null safety warning for conditional resource access -output clusterId string = !useAzureDataExplorer ? '' : analytics.outputs.clusterId +output clusterId string = !useAzureDataExplorer ? '' : analytics.outputs.metadata.cluster.id @description('The URI of the Data Explorer cluster.') #disable-next-line BCP318 // Null safety warning for conditional resource access -output clusterUri string = useFabric ? fabricQueryUri : (!useAzureDataExplorer ? '' : analytics.outputs.clusterUri) +output clusterUri string = useFabric ? fabricQueryUri : (!useAzureDataExplorer ? '' : analytics.outputs.metadata.cluster.uri) @description('The name of the Data Explorer database used for ingesting data.') #disable-next-line BCP318 // Null safety warning for conditional resource access -output ingestionDbName string = useFabric || useAzureDataExplorer ? analytics.outputs.ingestionDbName : '' +output ingestionDbName string = useFabric || useAzureDataExplorer ? analytics.outputs.metadata.databases.ingestion : '' @description('The name of the Data Explorer database used for querying data.') #disable-next-line BCP318 // Null safety warning for conditional resource access -output hubDbName string = useFabric || useAzureDataExplorer ? analytics.outputs.hubDbName : '' +output hubDbName string = useFabric || useAzureDataExplorer ? analytics.outputs.metadata.databases.hub : '' @description('Object ID of the Data Factory managed identity. This will be needed when configuring managed exports.') -output managedIdentityId string = core.outputs.principalId +output managedIdentityId string = core.outputs.metadata.principalId @description('Azure AD tenant ID. This will be needed when configuring managed exports.') output managedIdentityTenantId string = tenant().tenantId diff --git a/src/templates/finops-hub/package-manifest.json b/src/templates/finops-hub/package-manifest.json new file mode 100644 index 000000000..c371bca8f --- /dev/null +++ b/src/templates/finops-hub/package-manifest.json @@ -0,0 +1,12 @@ +{ + "deployment": { + "files": [ + { + "sourceFolder": "modules", + "source": "metadata.bicep", + "recurse": true, + "destination": "*" + } + ] + } +}