diff --git a/bc-backup.conf.template b/bc-backup.conf.template index 4f20df4..fac1b6f 100644 --- a/bc-backup.conf.template +++ b/bc-backup.conf.template @@ -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 diff --git a/bc-backup.sh b/bc-backup.sh index 763d68b..fced206 100755 --- a/bc-backup.sh +++ b/bc-backup.sh @@ -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" diff --git a/bc-export.ps1 b/bc-export.ps1 index cdfc19e..2a9523f 100755 --- a/bc-export.ps1 +++ b/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 + + 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 } - # 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 - - 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"