#!/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 if (-not $apiVersion) { $apiVersion = "v2.21" } if (-not $storageContainer) { $storageContainer = "bc-exports" } $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 { 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 = @{ "Authorization" = "Bearer $Token" "Content-Type" = "application/json" } $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 -Body $body 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 { param( [string]$Token, [datetime]$StartTime, [datetime]$EndTime ) $headers = @{ "Authorization" = "Bearer $Token" "Content-Type" = "application/json" } $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 $historyUrl -Method Post -Headers $headers return $response } catch { Write-Log "Failed to get export history: $_" "ERROR" return $null } } function Wait-ForExport { param( [string]$Token, [string]$EnvironmentName, [string]$BlobName, [datetime]$ExportStartTime, [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) 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 } # 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 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.ToLower()) { "completed" { Write-Log "Export completed successfully" return $ourExport } "complete" { Write-Log "Export completed successfully" return $ourExport } "failed" { Write-Log "Export failed" "ERROR" if ($ourExport.failureReason) { Write-Log "Failure reason: $($ourExport.failureReason)" "ERROR" } return $null } "inprogress" { Write-Log "Export in progress..." } "queued" { Write-Log "Export queued..." } default { Write-Log "Unknown status: $exportStatus" "WARN" } } Start-Sleep -Seconds $pollInterval } } function Download-FromAzureStorage { param( [string]$StorageSasUri, [string]$Container, [string]$BlobName, [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 $downloadUrl = "$baseStorageUrl/$Container/$BlobName$sasToken" Write-Log "Downloading BACPAC from Azure Storage..." Write-Log "Container: $Container" Write-Log "Blob: $BlobName" Write-Log "Saving to: $OutputPath" try { $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $downloadUrl -OutFile $OutputPath -UseBasicParsing 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 } # 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 if ($metrics -and $metrics.exportsRemainingThisMonth -le 0) { Write-Log "No exports remaining this month! (Limit reached)" "ERROR" exit 1 } # 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 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" 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 }