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/
|
# Find this in BC Admin Center: https://businesscentral.dynamics.com/
|
||||||
BC_ENVIRONMENT_NAME=""
|
BC_ENVIRONMENT_NAME=""
|
||||||
|
|
||||||
# BC Admin API version (default: v2.15, adjust if needed)
|
# BC Admin API version (default: v2.21)
|
||||||
BC_API_VERSION="v2.15"
|
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
|
# Encryption Configuration
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ required_vars=(
|
|||||||
"AZURE_CLIENT_ID"
|
"AZURE_CLIENT_ID"
|
||||||
"AZURE_CLIENT_SECRET"
|
"AZURE_CLIENT_SECRET"
|
||||||
"BC_ENVIRONMENT_NAME"
|
"BC_ENVIRONMENT_NAME"
|
||||||
|
"AZURE_STORAGE_SAS_URI"
|
||||||
"ENCRYPTION_PASSPHRASE"
|
"ENCRYPTION_PASSPHRASE"
|
||||||
"S3_BUCKET"
|
"S3_BUCKET"
|
||||||
"S3_ENDPOINT"
|
"S3_ENDPOINT"
|
||||||
@@ -58,7 +59,7 @@ RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
|||||||
S3_TOOL="${S3_TOOL:-awscli}"
|
S3_TOOL="${S3_TOOL:-awscli}"
|
||||||
MAX_RETRIES="${MAX_RETRIES:-3}"
|
MAX_RETRIES="${MAX_RETRIES:-3}"
|
||||||
CLEANUP_LOCAL="${CLEANUP_LOCAL:-true}"
|
CLEANUP_LOCAL="${CLEANUP_LOCAL:-true}"
|
||||||
BC_API_VERSION="${BC_API_VERSION:-v2.15}"
|
BC_API_VERSION="${BC_API_VERSION:-v2.21}"
|
||||||
|
|
||||||
log "========================================="
|
log "========================================="
|
||||||
log "Starting Business Central backup process"
|
log "Starting Business Central backup process"
|
||||||
@@ -79,6 +80,8 @@ export AZURE_CLIENT_ID
|
|||||||
export AZURE_CLIENT_SECRET
|
export AZURE_CLIENT_SECRET
|
||||||
export BC_ENVIRONMENT_NAME
|
export BC_ENVIRONMENT_NAME
|
||||||
export BC_API_VERSION
|
export BC_API_VERSION
|
||||||
|
export AZURE_STORAGE_SAS_URI
|
||||||
|
export AZURE_STORAGE_CONTAINER
|
||||||
export WORK_DIR
|
export WORK_DIR
|
||||||
|
|
||||||
BACPAC_FILE="${WORK_DIR}/${BACKUP_FILENAME}.bacpac"
|
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
|
$clientSecret = $env:AZURE_CLIENT_SECRET
|
||||||
$environmentName = $env:BC_ENVIRONMENT_NAME
|
$environmentName = $env:BC_ENVIRONMENT_NAME
|
||||||
$apiVersion = $env:BC_API_VERSION
|
$apiVersion = $env:BC_API_VERSION
|
||||||
|
$storageAccountSasUri = $env:AZURE_STORAGE_SAS_URI
|
||||||
|
$storageContainer = $env:AZURE_STORAGE_CONTAINER
|
||||||
|
|
||||||
if (-not $apiVersion) {
|
if (-not $apiVersion) {
|
||||||
$apiVersion = "v2.15"
|
$apiVersion = "v2.21"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $storageContainer) {
|
||||||
|
$storageContainer = "bc-exports"
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = "https://api.businesscentral.dynamics.com/admin/$apiVersion"
|
$baseUrl = "https://api.businesscentral.dynamics.com/admin/$apiVersion"
|
||||||
@@ -57,12 +63,38 @@ function Get-AzureADToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Start-DatabaseExport {
|
function Get-ExportMetrics {
|
||||||
param(
|
param(
|
||||||
[string]$Token,
|
[string]$Token,
|
||||||
[string]$EnvironmentName
|
[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"
|
Write-Log "Initiating database export for environment: $EnvironmentName"
|
||||||
|
|
||||||
$headers = @{
|
$headers = @{
|
||||||
@@ -70,10 +102,16 @@ function Start-DatabaseExport {
|
|||||||
"Content-Type" = "application/json"
|
"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 {
|
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"
|
Write-Log "Database export initiated successfully"
|
||||||
return $response
|
return $response
|
||||||
}
|
}
|
||||||
@@ -84,24 +122,29 @@ function Start-DatabaseExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ExportStatus {
|
function Get-ExportHistory {
|
||||||
param(
|
param(
|
||||||
[string]$Token,
|
[string]$Token,
|
||||||
[string]$EnvironmentName
|
[datetime]$StartTime,
|
||||||
|
[datetime]$EndTime
|
||||||
)
|
)
|
||||||
|
|
||||||
$headers = @{
|
$headers = @{
|
||||||
"Authorization" = "Bearer $Token"
|
"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 {
|
try {
|
||||||
$response = Invoke-RestMethod -Uri $statusUrl -Method Get -Headers $headers
|
$response = Invoke-RestMethod -Uri $historyUrl -Method Post -Headers $headers
|
||||||
return $response
|
return $response
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Log "Failed to get export status: $_" "ERROR"
|
Write-Log "Failed to get export history: $_" "ERROR"
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +153,8 @@ function Wait-ForExport {
|
|||||||
param(
|
param(
|
||||||
[string]$Token,
|
[string]$Token,
|
||||||
[string]$EnvironmentName,
|
[string]$EnvironmentName,
|
||||||
|
[string]$BlobName,
|
||||||
|
[datetime]$ExportStartTime,
|
||||||
[int]$MaxWaitMinutes = 120
|
[int]$MaxWaitMinutes = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,34 +171,52 @@ function Wait-ForExport {
|
|||||||
return $null
|
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) {
|
if ($null -eq $history -or $null -eq $history.value -or $history.value.Count -eq 0) {
|
||||||
Write-Log "No export found, waiting..." "WARN"
|
Write-Log "No export history found yet, waiting... (Elapsed: $([math]::Round($elapsed, 1)) min)"
|
||||||
Start-Sleep -Seconds $pollInterval
|
Start-Sleep -Seconds $pollInterval
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the most recent export
|
# Find our export by environment name and blob name
|
||||||
$latestExport = $status.value | Sort-Object -Property createdOn -Descending | Select-Object -First 1
|
$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)"
|
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" {
|
"complete" {
|
||||||
Write-Log "Export completed successfully"
|
Write-Log "Export completed successfully"
|
||||||
return $latestExport
|
return $ourExport
|
||||||
}
|
}
|
||||||
"failed" {
|
"failed" {
|
||||||
Write-Log "Export failed" "ERROR"
|
Write-Log "Export failed" "ERROR"
|
||||||
if ($latestExport.failureReason) {
|
if ($ourExport.failureReason) {
|
||||||
Write-Log "Failure reason: $($latestExport.failureReason)" "ERROR"
|
Write-Log "Failure reason: $($ourExport.failureReason)" "ERROR"
|
||||||
}
|
}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
"inProgress" {
|
"inprogress" {
|
||||||
Write-Log "Export in progress..."
|
Write-Log "Export in progress..."
|
||||||
}
|
}
|
||||||
"queued" {
|
"queued" {
|
||||||
@@ -168,26 +231,30 @@ function Wait-ForExport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Download-Export {
|
function Download-FromAzureStorage {
|
||||||
param(
|
param(
|
||||||
[object]$Export,
|
[string]$StorageSasUri,
|
||||||
|
[string]$Container,
|
||||||
|
[string]$BlobName,
|
||||||
[string]$OutputPath
|
[string]$OutputPath
|
||||||
)
|
)
|
||||||
|
|
||||||
if (-not $Export.blobUri) {
|
# Parse the SAS URI to construct the blob download URL
|
||||||
Write-Log "No download URI available in export object" "ERROR"
|
# SAS URI format: https://account.blob.core.windows.net?sv=...&sig=...
|
||||||
return $false
|
$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"
|
Write-Log "Saving to: $OutputPath"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# Use Invoke-WebRequest for better progress tracking
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
$ProgressPreference = 'SilentlyContinue' # Disable progress bar for better performance
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $OutputPath -UseBasicParsing
|
||||||
Invoke-WebRequest -Uri $downloadUri -OutFile $OutputPath -UseBasicParsing
|
|
||||||
|
|
||||||
if (Test-Path $OutputPath) {
|
if (Test-Path $OutputPath) {
|
||||||
$fileSize = (Get-Item $OutputPath).Length
|
$fileSize = (Get-Item $OutputPath).Length
|
||||||
@@ -215,68 +282,56 @@ try {
|
|||||||
Write-Log "API Version: $apiVersion"
|
Write-Log "API Version: $apiVersion"
|
||||||
Write-Log "Output Path: $OutputPath"
|
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
|
# Step 1: Get Azure AD token
|
||||||
$token = Get-AzureADToken -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
|
$token = Get-AzureADToken -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
|
||||||
|
|
||||||
# Step 2: Check for existing in-progress exports
|
# Step 2: Check export metrics
|
||||||
Write-Log "Checking for existing exports..."
|
Write-Log "Checking export metrics..."
|
||||||
$existingStatus = Get-ExportStatus -Token $token -EnvironmentName $environmentName
|
$metrics = Get-ExportMetrics -Token $token -EnvironmentName $environmentName
|
||||||
|
|
||||||
$activeExport = $null
|
if ($metrics -and $metrics.exportsRemainingThisMonth -le 0) {
|
||||||
if ($existingStatus -and $existingStatus.value.Count -gt 0) {
|
Write-Log "No exports remaining this month! (Limit reached)" "ERROR"
|
||||||
$latestExport = $existingStatus.value | Sort-Object -Property createdOn -Descending | Select-Object -First 1
|
exit 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3: Start new export if needed
|
# Step 3: Start the export
|
||||||
if (-not $activeExport) {
|
$blobName = "bc_export_${environmentName}_$(Get-Date -Format 'yyyyMMdd_HHmmss').bacpac"
|
||||||
$activeExport = Start-DatabaseExport -Token $token -EnvironmentName $environmentName
|
$exportStartTime = (Get-Date).AddMinutes(-1) # slight buffer for clock differences
|
||||||
}
|
|
||||||
|
|
||||||
# Step 4: Wait for export to complete (if not already complete)
|
Write-Log "Starting export to Azure Storage (container: $storageContainer, blob: $blobName)..."
|
||||||
if ($activeExport.status -ne "complete") {
|
|
||||||
$completedExport = Wait-ForExport -Token $token -EnvironmentName $environmentName -MaxWaitMinutes 120
|
$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) {
|
if (-not $completedExport) {
|
||||||
Write-Log "Export did not complete successfully" "ERROR"
|
Write-Log "Export did not complete successfully" "ERROR"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeExport = $completedExport
|
# Step 5: Download the BACPAC from Azure Storage
|
||||||
}
|
$downloadSuccess = Download-FromAzureStorage `
|
||||||
|
-StorageSasUri $storageAccountSasUri `
|
||||||
# Step 5: Download the BACPAC file
|
-Container $storageContainer `
|
||||||
$downloadSuccess = Download-Export -Export $activeExport -OutputPath $OutputPath
|
-BlobName $blobName `
|
||||||
|
-OutputPath $OutputPath
|
||||||
|
|
||||||
if (-not $downloadSuccess) {
|
if (-not $downloadSuccess) {
|
||||||
Write-Log "Failed to download export" "ERROR"
|
Write-Log "Failed to download export" "ERROR"
|
||||||
|
|||||||
Reference in New Issue
Block a user