fix: correct BC Admin Center API endpoints for database export

- Fix endpoint paths from /applications/businesscentral/environments/{env}/databaseExports
  to /exports/applications/BusinessCentral/environments/{env}
- Add Azure Storage SAS URI support (required by current export API)
- Update default API version from v2.15 to v2.21
- Add export metrics check before initiating export
- Use export history endpoint for status polling
- Download BACPAC from Azure Storage instead of expecting blobUri response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 19:21:06 +01:00
parent d35806b8e1
commit 96237787da
3 changed files with 166 additions and 88 deletions

View File

@@ -40,8 +40,28 @@ AZURE_CLIENT_SECRET=""
# Find this in BC Admin Center: https://businesscentral.dynamics.com/
BC_ENVIRONMENT_NAME=""
# BC Admin API version (default: v2.15, adjust if needed)
BC_API_VERSION="v2.15"
# BC Admin API version (default: v2.21)
BC_API_VERSION="v2.21"
# ===================================
# Azure Storage Configuration
# ===================================
# The BC Admin Center API exports the database to your Azure Storage account.
# You need an Azure Storage account with a SAS URI that has Read, Write, Create, Delete permissions.
#
# To create a SAS URI:
# 1. Go to Azure Portal > Storage Accounts > your account
# 2. Go to "Shared access signature"
# 3. Enable: Blob service, Container+Object resource types, Read+Write+Create+Delete permissions
# 4. Set an appropriate expiry date
# 5. Copy the generated SAS URL
# Azure Storage Account SAS URI (full URI with SAS token)
# Example: https://youraccount.blob.core.windows.net?sv=2021-06-08&ss=b&srt=sco&sp=rwdlac&se=...&sig=...
AZURE_STORAGE_SAS_URI=""
# Azure Storage container name for exports (will be created automatically)
AZURE_STORAGE_CONTAINER="bc-exports"
# ===================================
# Encryption Configuration

View File

@@ -39,6 +39,7 @@ required_vars=(
"AZURE_CLIENT_ID"
"AZURE_CLIENT_SECRET"
"BC_ENVIRONMENT_NAME"
"AZURE_STORAGE_SAS_URI"
"ENCRYPTION_PASSPHRASE"
"S3_BUCKET"
"S3_ENDPOINT"
@@ -58,7 +59,7 @@ RETENTION_DAYS="${RETENTION_DAYS:-30}"
S3_TOOL="${S3_TOOL:-awscli}"
MAX_RETRIES="${MAX_RETRIES:-3}"
CLEANUP_LOCAL="${CLEANUP_LOCAL:-true}"
BC_API_VERSION="${BC_API_VERSION:-v2.15}"
BC_API_VERSION="${BC_API_VERSION:-v2.21}"
log "========================================="
log "Starting Business Central backup process"
@@ -79,6 +80,8 @@ export AZURE_CLIENT_ID
export AZURE_CLIENT_SECRET
export BC_ENVIRONMENT_NAME
export BC_API_VERSION
export AZURE_STORAGE_SAS_URI
export AZURE_STORAGE_CONTAINER
export WORK_DIR
BACPAC_FILE="${WORK_DIR}/${BACKUP_FILENAME}.bacpac"

View File

@@ -15,9 +15,15 @@ $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
if (-not $apiVersion) {
$apiVersion = "v2.15"
$apiVersion = "v2.21"
}
if (-not $storageContainer) {
$storageContainer = "bc-exports"
}
$baseUrl = "https://api.businesscentral.dynamics.com/admin/$apiVersion"
@@ -57,12 +63,38 @@ function Get-AzureADToken {
}
}
function Start-DatabaseExport {
function Get-ExportMetrics {
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
)
Write-Log "Initiating database export for environment: $EnvironmentName"
$headers = @{
@@ -70,10 +102,16 @@ function Start-DatabaseExport {
"Content-Type" = "application/json"
}
$exportUrl = "$baseUrl/applications/businesscentral/environments/$EnvironmentName/databaseExports"
$exportUrl = "$baseUrl/exports/applications/BusinessCentral/environments/$EnvironmentName"
$body = @{
storageAccountSasUri = $StorageSasUri
container = $Container
blob = $BlobName
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri $exportUrl -Method Post -Headers $headers
$response = Invoke-RestMethod -Uri $exportUrl -Method Post -Headers $headers -Body $body
Write-Log "Database export initiated successfully"
return $response
}
@@ -84,24 +122,29 @@ function Start-DatabaseExport {
}
}
function Get-ExportStatus {
function Get-ExportHistory {
param(
[string]$Token,
[string]$EnvironmentName
[datetime]$StartTime,
[datetime]$EndTime
)
$headers = @{
"Authorization" = "Bearer $Token"
"Content-Type" = "application/json"
}
$statusUrl = "$baseUrl/applications/businesscentral/environments/$EnvironmentName/databaseExports"
$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"
try {
$response = Invoke-RestMethod -Uri $statusUrl -Method Get -Headers $headers
$response = Invoke-RestMethod -Uri $historyUrl -Method Post -Headers $headers
return $response
}
catch {
Write-Log "Failed to get export status: $_" "ERROR"
Write-Log "Failed to get export history: $_" "ERROR"
return $null
}
}
@@ -110,6 +153,8 @@ function Wait-ForExport {
param(
[string]$Token,
[string]$EnvironmentName,
[string]$BlobName,
[datetime]$ExportStartTime,
[int]$MaxWaitMinutes = 120
)
@@ -126,34 +171,52 @@ function Wait-ForExport {
return $null
}
$status = Get-ExportStatus -Token $Token -EnvironmentName $EnvironmentName
$history = Get-ExportHistory -Token $Token -StartTime $ExportStartTime -EndTime (Get-Date)
if ($null -eq $status -or $status.value.Count -eq 0) {
Write-Log "No export found, waiting..." "WARN"
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)"
Start-Sleep -Seconds $pollInterval
continue
}
# Get the most recent export
$latestExport = $status.value | Sort-Object -Property createdOn -Descending | Select-Object -First 1
# 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
$exportStatus = $latestExport.status
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
}
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
Write-Log "Export status: $exportStatus (Elapsed: $([math]::Round($elapsed, 1)) min)"
switch ($exportStatus) {
switch ($exportStatus.ToLower()) {
"completed" {
Write-Log "Export completed successfully"
return $ourExport
}
"complete" {
Write-Log "Export completed successfully"
return $latestExport
return $ourExport
}
"failed" {
Write-Log "Export failed" "ERROR"
if ($latestExport.failureReason) {
Write-Log "Failure reason: $($latestExport.failureReason)" "ERROR"
if ($ourExport.failureReason) {
Write-Log "Failure reason: $($ourExport.failureReason)" "ERROR"
}
return $null
}
"inProgress" {
"inprogress" {
Write-Log "Export in progress..."
}
"queued" {
@@ -168,26 +231,30 @@ function Wait-ForExport {
}
}
function Download-Export {
function Download-FromAzureStorage {
param(
[object]$Export,
[string]$StorageSasUri,
[string]$Container,
[string]$BlobName,
[string]$OutputPath
)
if (-not $Export.blobUri) {
Write-Log "No download URI available in export object" "ERROR"
return $false
}
# 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
$downloadUri = $Export.blobUri
$downloadUrl = "$baseStorageUrl/$Container/$BlobName$sasToken"
Write-Log "Downloading BACPAC from: $downloadUri"
Write-Log "Downloading BACPAC from Azure Storage..."
Write-Log "Container: $Container"
Write-Log "Blob: $BlobName"
Write-Log "Saving to: $OutputPath"
try {
# Use Invoke-WebRequest for better progress tracking
$ProgressPreference = 'SilentlyContinue' # Disable progress bar for better performance
Invoke-WebRequest -Uri $downloadUri -OutFile $OutputPath -UseBasicParsing
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $downloadUrl -OutFile $OutputPath -UseBasicParsing
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length
@@ -215,68 +282,56 @@ try {
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
}
# Step 1: Get Azure AD token
$token = Get-AzureADToken -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
# Step 2: Check for existing in-progress exports
Write-Log "Checking for existing exports..."
$existingStatus = Get-ExportStatus -Token $token -EnvironmentName $environmentName
# Step 2: Check export metrics
Write-Log "Checking export metrics..."
$metrics = Get-ExportMetrics -Token $token -EnvironmentName $environmentName
$activeExport = $null
if ($existingStatus -and $existingStatus.value.Count -gt 0) {
$latestExport = $existingStatus.value | Sort-Object -Property createdOn -Descending | Select-Object -First 1
if ($latestExport.status -eq "inProgress" -or $latestExport.status -eq "queued") {
Write-Log "Found existing export in progress (created: $($latestExport.createdOn))"
$activeExport = $latestExport
# Ask if we should wait for it or start a new one
$created = [DateTime]::Parse($latestExport.createdOn)
$age = (Get-Date) - $created
if ($age.TotalHours -lt 2) {
Write-Log "Existing export is recent (age: $([math]::Round($age.TotalMinutes, 1)) min), will wait for it"
}
else {
Write-Log "Existing export is old (age: $([math]::Round($age.TotalHours, 1)) hours), starting new export" "WARN"
$activeExport = Start-DatabaseExport -Token $token -EnvironmentName $environmentName
}
}
elseif ($latestExport.status -eq "complete") {
$created = [DateTime]::Parse($latestExport.createdOn)
$age = (Get-Date) - $created
if ($age.TotalHours -lt 1) {
Write-Log "Found recent completed export (age: $([math]::Round($age.TotalMinutes, 1)) min)"
Write-Log "Using existing export to avoid API limits"
$activeExport = $latestExport
}
else {
Write-Log "Latest export is too old, starting new export"
$activeExport = Start-DatabaseExport -Token $token -EnvironmentName $environmentName
}
}
if ($metrics -and $metrics.exportsRemainingThisMonth -le 0) {
Write-Log "No exports remaining this month! (Limit reached)" "ERROR"
exit 1
}
# Step 3: Start new export if needed
if (-not $activeExport) {
$activeExport = Start-DatabaseExport -Token $token -EnvironmentName $environmentName
}
# 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
# Step 4: Wait for export to complete (if not already complete)
if ($activeExport.status -ne "complete") {
$completedExport = Wait-ForExport -Token $token -EnvironmentName $environmentName -MaxWaitMinutes 120
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
}
$activeExport = $completedExport
}
# Step 5: Download the BACPAC file
$downloadSuccess = Download-Export -Export $activeExport -OutputPath $OutputPath
# Step 5: Download the BACPAC from Azure Storage
$downloadSuccess = Download-FromAzureStorage `
-StorageSasUri $storageAccountSasUri `
-Container $storageContainer `
-BlobName $blobName `
-OutputPath $OutputPath
if (-not $downloadSuccess) {
Write-Log "Failed to download export" "ERROR"