Files
BC-bak/bc-export.ps1

351 lines
10 KiB
PowerShell
Raw Normal View History

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
$storageAccountSasUri = $env:AZURE_STORAGE_SAS_URI
$storageContainer = $env:AZURE_STORAGE_CONTAINER
2026-02-09 18:57:39 +01:00
if (-not $apiVersion) {
$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
}
}
function Get-ExportMetrics {
2026-02-09 18:57:39 +01:00
param(
[string]$Token,
[string]$EnvironmentName
)
$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"
}
$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 {
$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
}
}
function Get-ExportHistory {
2026-02-09 18:57:39 +01:00
param(
[string]$Token,
[datetime]$StartTime,
[datetime]$EndTime
2026-02-09 18:57:39 +01:00
)
$headers = @{
"Authorization" = "Bearer $Token"
"Content-Type" = "application/json"
2026-02-09 18:57:39 +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 {
$response = Invoke-RestMethod -Uri $historyUrl -Method Post -Headers $headers
2026-02-09 18:57:39 +01:00
return $response
}
catch {
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,
[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
}
$history = Get-ExportHistory -Token $Token -StartTime $ExportStartTime -EndTime (Get-Date)
2026-02-09 18:57:39 +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
}
# 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
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
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)"
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"
return $ourExport
2026-02-09 18:57:39 +01:00
}
"failed" {
Write-Log "Export failed" "ERROR"
if ($ourExport.failureReason) {
Write-Log "Failure reason: $($ourExport.failureReason)" "ERROR"
2026-02-09 18:57:39 +01:00
}
return $null
}
"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
}
}
function Download-FromAzureStorage {
2026-02-09 18:57:39 +01:00
param(
[string]$StorageSasUri,
[string]$Container,
[string]$BlobName,
2026-02-09 18:57:39 +01:00
[string]$OutputPath
)
# 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
$downloadUrl = "$baseStorageUrl/$Container/$BlobName$sasToken"
2026-02-09 18:57:39 +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 {
$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"
# 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
# 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
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
}
# 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
}
# 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
}