2026-02-09 18:57:39 +01:00
|
|
|
#!/usr/bin/env pwsh
|
|
|
|
|
#
|
|
|
|
|
# Business Central Database Export via Admin Center API
|
|
|
|
|
# Authenticates to Azure AD and exports BC database as BACPAC
|
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
param(
|
|
|
|
|
[Parameter(Mandatory=$true)]
|
|
|
|
|
[string]$OutputPath
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get configuration from environment variables
|
|
|
|
|
$tenantId = $env:AZURE_TENANT_ID
|
|
|
|
|
$clientId = $env:AZURE_CLIENT_ID
|
|
|
|
|
$clientSecret = $env:AZURE_CLIENT_SECRET
|
|
|
|
|
$environmentName = $env:BC_ENVIRONMENT_NAME
|
|
|
|
|
$apiVersion = $env:BC_API_VERSION
|
2026-02-09 19:21:06 +01:00
|
|
|
$storageAccountSasUri = $env:AZURE_STORAGE_SAS_URI
|
|
|
|
|
$storageContainer = $env:AZURE_STORAGE_CONTAINER
|
2026-02-09 18:57:39 +01:00
|
|
|
|
|
|
|
|
if (-not $apiVersion) {
|
2026-02-09 19:21:06 +01:00
|
|
|
$apiVersion = "v2.21"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (-not $storageContainer) {
|
|
|
|
|
$storageContainer = "bc-exports"
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$baseUrl = "https://api.businesscentral.dynamics.com/admin/$apiVersion"
|
|
|
|
|
|
|
|
|
|
function Write-Log {
|
|
|
|
|
param([string]$Message, [string]$Level = "INFO")
|
|
|
|
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
|
|
|
Write-Host "[$timestamp] [$Level] $Message"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Get-AzureADToken {
|
|
|
|
|
param(
|
|
|
|
|
[string]$TenantId,
|
|
|
|
|
[string]$ClientId,
|
|
|
|
|
[string]$ClientSecret
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Write-Log "Authenticating to Azure AD..."
|
|
|
|
|
|
|
|
|
|
$tokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
|
|
|
|
|
|
|
|
|
|
$body = @{
|
|
|
|
|
client_id = $ClientId
|
|
|
|
|
client_secret = $ClientSecret
|
|
|
|
|
scope = "https://api.businesscentral.dynamics.com/.default"
|
|
|
|
|
grant_type = "client_credentials"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$response = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"
|
|
|
|
|
Write-Log "Successfully authenticated to Azure AD"
|
|
|
|
|
return $response.access_token
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Log "Failed to authenticate: $_" "ERROR"
|
|
|
|
|
throw
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
function Get-ExportMetrics {
|
2026-02-09 18:57:39 +01:00
|
|
|
param(
|
|
|
|
|
[string]$Token,
|
|
|
|
|
[string]$EnvironmentName
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
$headers = @{
|
|
|
|
|
"Authorization" = "Bearer $Token"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$metricsUrl = "$baseUrl/exports/applications/BusinessCentral/environments/$EnvironmentName/metrics"
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$response = Invoke-RestMethod -Uri $metricsUrl -Method Get -Headers $headers
|
|
|
|
|
Write-Log "Export metrics - Used this month: $($response.exportsPerMonth), Remaining: $($response.exportsRemainingThisMonth)"
|
|
|
|
|
return $response
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Log "Failed to get export metrics: $_" "WARN"
|
|
|
|
|
return $null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Start-DatabaseExport {
|
|
|
|
|
param(
|
|
|
|
|
[string]$Token,
|
|
|
|
|
[string]$EnvironmentName,
|
|
|
|
|
[string]$StorageSasUri,
|
|
|
|
|
[string]$Container,
|
|
|
|
|
[string]$BlobName
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 18:57:39 +01:00
|
|
|
Write-Log "Initiating database export for environment: $EnvironmentName"
|
|
|
|
|
|
|
|
|
|
$headers = @{
|
|
|
|
|
"Authorization" = "Bearer $Token"
|
|
|
|
|
"Content-Type" = "application/json"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
$exportUrl = "$baseUrl/exports/applications/BusinessCentral/environments/$EnvironmentName"
|
|
|
|
|
|
|
|
|
|
$body = @{
|
|
|
|
|
storageAccountSasUri = $StorageSasUri
|
|
|
|
|
container = $Container
|
|
|
|
|
blob = $BlobName
|
|
|
|
|
} | ConvertTo-Json
|
2026-02-09 18:57:39 +01:00
|
|
|
|
|
|
|
|
try {
|
2026-02-09 19:21:06 +01:00
|
|
|
$response = Invoke-RestMethod -Uri $exportUrl -Method Post -Headers $headers -Body $body
|
2026-02-09 18:57:39 +01:00
|
|
|
Write-Log "Database export initiated successfully"
|
|
|
|
|
return $response
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Log "Failed to initiate export: $_" "ERROR"
|
|
|
|
|
Write-Log "Response: $($_.ErrorDetails.Message)" "ERROR"
|
|
|
|
|
throw
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
function Get-ExportHistory {
|
2026-02-09 18:57:39 +01:00
|
|
|
param(
|
|
|
|
|
[string]$Token,
|
2026-02-09 19:21:06 +01:00
|
|
|
[datetime]$StartTime,
|
|
|
|
|
[datetime]$EndTime
|
2026-02-09 18:57:39 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$headers = @{
|
|
|
|
|
"Authorization" = "Bearer $Token"
|
2026-02-09 19:21:06 +01:00
|
|
|
"Content-Type" = "application/json"
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
$startStr = $StartTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
|
|
|
$endStr = $EndTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
|
|
|
|
|
|
|
|
$historyUrl = "$baseUrl/exports/history?start=$startStr&end=$endStr"
|
2026-02-09 18:57:39 +01:00
|
|
|
|
|
|
|
|
try {
|
2026-02-09 19:21:06 +01:00
|
|
|
$response = Invoke-RestMethod -Uri $historyUrl -Method Post -Headers $headers
|
2026-02-09 18:57:39 +01:00
|
|
|
return $response
|
|
|
|
|
}
|
|
|
|
|
catch {
|
2026-02-09 19:21:06 +01:00
|
|
|
Write-Log "Failed to get export history: $_" "ERROR"
|
2026-02-09 18:57:39 +01:00
|
|
|
return $null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Wait-ForExport {
|
|
|
|
|
param(
|
|
|
|
|
[string]$Token,
|
|
|
|
|
[string]$EnvironmentName,
|
2026-02-09 19:21:06 +01:00
|
|
|
[string]$BlobName,
|
|
|
|
|
[datetime]$ExportStartTime,
|
2026-02-09 18:57:39 +01:00
|
|
|
[int]$MaxWaitMinutes = 120
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Write-Log "Waiting for export to complete (max $MaxWaitMinutes minutes)..."
|
|
|
|
|
|
|
|
|
|
$startTime = Get-Date
|
|
|
|
|
$pollInterval = 30 # seconds
|
|
|
|
|
|
|
|
|
|
while ($true) {
|
|
|
|
|
$elapsed = ((Get-Date) - $startTime).TotalMinutes
|
|
|
|
|
|
|
|
|
|
if ($elapsed -gt $MaxWaitMinutes) {
|
|
|
|
|
Write-Log "Export timeout exceeded ($MaxWaitMinutes minutes)" "ERROR"
|
|
|
|
|
return $null
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
$history = Get-ExportHistory -Token $Token -StartTime $ExportStartTime -EndTime (Get-Date)
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
if ($null -eq $history -or $null -eq $history.value -or $history.value.Count -eq 0) {
|
|
|
|
|
Write-Log "No export history found yet, waiting... (Elapsed: $([math]::Round($elapsed, 1)) min)"
|
2026-02-09 18:57:39 +01:00
|
|
|
Start-Sleep -Seconds $pollInterval
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Find our export by environment name and blob name
|
|
|
|
|
$ourExport = $history.value | Where-Object {
|
|
|
|
|
$_.environmentName -eq $EnvironmentName -and $_.blob -eq $BlobName
|
|
|
|
|
} | Sort-Object -Property startedOn -Descending | Select-Object -First 1
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
if (-not $ourExport) {
|
|
|
|
|
# Fallback: just find the most recent export for this environment
|
|
|
|
|
$ourExport = $history.value | Where-Object {
|
|
|
|
|
$_.environmentName -eq $EnvironmentName
|
|
|
|
|
} | Sort-Object -Property startedOn -Descending | Select-Object -First 1
|
|
|
|
|
}
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
if (-not $ourExport) {
|
|
|
|
|
Write-Log "Export not found in history yet, waiting... (Elapsed: $([math]::Round($elapsed, 1)) min)"
|
|
|
|
|
Start-Sleep -Seconds $pollInterval
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$exportStatus = $ourExport.status
|
2026-02-09 18:57:39 +01:00
|
|
|
Write-Log "Export status: $exportStatus (Elapsed: $([math]::Round($elapsed, 1)) min)"
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
switch ($exportStatus.ToLower()) {
|
|
|
|
|
"completed" {
|
|
|
|
|
Write-Log "Export completed successfully"
|
|
|
|
|
return $ourExport
|
|
|
|
|
}
|
2026-02-09 18:57:39 +01:00
|
|
|
"complete" {
|
|
|
|
|
Write-Log "Export completed successfully"
|
2026-02-09 19:21:06 +01:00
|
|
|
return $ourExport
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
"failed" {
|
|
|
|
|
Write-Log "Export failed" "ERROR"
|
2026-02-09 19:21:06 +01:00
|
|
|
if ($ourExport.failureReason) {
|
|
|
|
|
Write-Log "Failure reason: $($ourExport.failureReason)" "ERROR"
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
return $null
|
|
|
|
|
}
|
2026-02-09 19:21:06 +01:00
|
|
|
"inprogress" {
|
2026-02-09 18:57:39 +01:00
|
|
|
Write-Log "Export in progress..."
|
|
|
|
|
}
|
|
|
|
|
"queued" {
|
|
|
|
|
Write-Log "Export queued..."
|
|
|
|
|
}
|
|
|
|
|
default {
|
|
|
|
|
Write-Log "Unknown status: $exportStatus" "WARN"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Start-Sleep -Seconds $pollInterval
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
function Download-FromAzureStorage {
|
2026-02-09 18:57:39 +01:00
|
|
|
param(
|
2026-02-09 19:21:06 +01:00
|
|
|
[string]$StorageSasUri,
|
|
|
|
|
[string]$Container,
|
|
|
|
|
[string]$BlobName,
|
2026-02-09 18:57:39 +01:00
|
|
|
[string]$OutputPath
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Parse the SAS URI to construct the blob download URL
|
|
|
|
|
# SAS URI format: https://account.blob.core.windows.net?sv=...&sig=...
|
|
|
|
|
$uri = [System.Uri]$StorageSasUri
|
|
|
|
|
$baseStorageUrl = "$($uri.Scheme)://$($uri.Host)"
|
|
|
|
|
$sasToken = $uri.Query
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
$downloadUrl = "$baseStorageUrl/$Container/$BlobName$sasToken"
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
Write-Log "Downloading BACPAC from Azure Storage..."
|
|
|
|
|
Write-Log "Container: $Container"
|
|
|
|
|
Write-Log "Blob: $BlobName"
|
2026-02-09 18:57:39 +01:00
|
|
|
Write-Log "Saving to: $OutputPath"
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-09 19:21:06 +01:00
|
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
|
|
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $OutputPath -UseBasicParsing
|
2026-02-09 18:57:39 +01:00
|
|
|
|
|
|
|
|
if (Test-Path $OutputPath) {
|
|
|
|
|
$fileSize = (Get-Item $OutputPath).Length
|
|
|
|
|
$fileSizeMB = [math]::Round($fileSize / 1MB, 2)
|
|
|
|
|
Write-Log "Download completed successfully ($fileSizeMB MB)"
|
|
|
|
|
return $true
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
Write-Log "Download failed - file not found" "ERROR"
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Log "Download failed: $_" "ERROR"
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Main execution
|
|
|
|
|
try {
|
|
|
|
|
Write-Log "========================================="
|
|
|
|
|
Write-Log "BC Database Export Script"
|
|
|
|
|
Write-Log "========================================="
|
|
|
|
|
Write-Log "Environment: $environmentName"
|
|
|
|
|
Write-Log "API Version: $apiVersion"
|
|
|
|
|
Write-Log "Output Path: $OutputPath"
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Validate required parameters
|
|
|
|
|
if (-not $storageAccountSasUri) {
|
|
|
|
|
Write-Log "AZURE_STORAGE_SAS_URI is required for database export" "ERROR"
|
|
|
|
|
exit 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 18:57:39 +01:00
|
|
|
# Step 1: Get Azure AD token
|
|
|
|
|
$token = Get-AzureADToken -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Step 2: Check export metrics
|
|
|
|
|
Write-Log "Checking export metrics..."
|
|
|
|
|
$metrics = Get-ExportMetrics -Token $token -EnvironmentName $environmentName
|
2026-02-09 18:57:39 +01:00
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
if ($metrics -and $metrics.exportsRemainingThisMonth -le 0) {
|
|
|
|
|
Write-Log "No exports remaining this month! (Limit reached)" "ERROR"
|
|
|
|
|
exit 1
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Step 3: Start the export
|
|
|
|
|
$blobName = "bc_export_${environmentName}_$(Get-Date -Format 'yyyyMMdd_HHmmss').bacpac"
|
|
|
|
|
$exportStartTime = (Get-Date).AddMinutes(-1) # slight buffer for clock differences
|
|
|
|
|
|
|
|
|
|
Write-Log "Starting export to Azure Storage (container: $storageContainer, blob: $blobName)..."
|
|
|
|
|
|
|
|
|
|
$exportResult = Start-DatabaseExport `
|
|
|
|
|
-Token $token `
|
|
|
|
|
-EnvironmentName $environmentName `
|
|
|
|
|
-StorageSasUri $storageAccountSasUri `
|
|
|
|
|
-Container $storageContainer `
|
|
|
|
|
-BlobName $blobName
|
|
|
|
|
|
|
|
|
|
# Step 4: Wait for export to complete
|
|
|
|
|
$completedExport = Wait-ForExport `
|
|
|
|
|
-Token $token `
|
|
|
|
|
-EnvironmentName $environmentName `
|
|
|
|
|
-BlobName $blobName `
|
|
|
|
|
-ExportStartTime $exportStartTime `
|
|
|
|
|
-MaxWaitMinutes 120
|
|
|
|
|
|
|
|
|
|
if (-not $completedExport) {
|
|
|
|
|
Write-Log "Export did not complete successfully" "ERROR"
|
|
|
|
|
exit 1
|
2026-02-09 18:57:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 19:21:06 +01:00
|
|
|
# Step 5: Download the BACPAC from Azure Storage
|
|
|
|
|
$downloadSuccess = Download-FromAzureStorage `
|
|
|
|
|
-StorageSasUri $storageAccountSasUri `
|
|
|
|
|
-Container $storageContainer `
|
|
|
|
|
-BlobName $blobName `
|
|
|
|
|
-OutputPath $OutputPath
|
2026-02-09 18:57:39 +01:00
|
|
|
|
|
|
|
|
if (-not $downloadSuccess) {
|
|
|
|
|
Write-Log "Failed to download export" "ERROR"
|
|
|
|
|
exit 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Write-Log "========================================="
|
|
|
|
|
Write-Log "Export completed successfully"
|
|
|
|
|
Write-Log "========================================="
|
|
|
|
|
exit 0
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Log "Unexpected error: $_" "ERROR"
|
|
|
|
|
Write-Log "Stack trace: $($_.ScriptStackTrace)" "ERROR"
|
|
|
|
|
exit 1
|
|
|
|
|
}
|