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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
215
bc-export.ps1
215
bc-export.ps1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user