#!/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 if (-not $apiVersion) { $apiVersion = "v2.15" } $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 Start-DatabaseExport { param( [string]$Token, [string]$EnvironmentName ) Write-Log "Initiating database export for environment: $EnvironmentName" $headers = @{ "Authorization" = "Bearer $Token" "Content-Type" = "application/json" } $exportUrl = "$baseUrl/applications/businesscentral/environments/$EnvironmentName/databaseExports" try { $response = Invoke-RestMethod -Uri $exportUrl -Method Post -Headers $headers 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-ExportStatus { param( [string]$Token, [string]$EnvironmentName ) $headers = @{ "Authorization" = "Bearer $Token" } $statusUrl = "$baseUrl/applications/businesscentral/environments/$EnvironmentName/databaseExports" try { $response = Invoke-RestMethod -Uri $statusUrl -Method Get -Headers $headers return $response } catch { Write-Log "Failed to get export status: $_" "ERROR" return $null } } function Wait-ForExport { param( [string]$Token, [string]$EnvironmentName, [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 } $status = Get-ExportStatus -Token $Token -EnvironmentName $EnvironmentName if ($null -eq $status -or $status.value.Count -eq 0) { Write-Log "No export found, waiting..." "WARN" Start-Sleep -Seconds $pollInterval continue } # Get the most recent export $latestExport = $status.value | Sort-Object -Property createdOn -Descending | Select-Object -First 1 $exportStatus = $latestExport.status Write-Log "Export status: $exportStatus (Elapsed: $([math]::Round($elapsed, 1)) min)" switch ($exportStatus) { "complete" { Write-Log "Export completed successfully" return $latestExport } "failed" { Write-Log "Export failed" "ERROR" if ($latestExport.failureReason) { Write-Log "Failure reason: $($latestExport.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-Export { param( [object]$Export, [string]$OutputPath ) if (-not $Export.blobUri) { Write-Log "No download URI available in export object" "ERROR" return $false } $downloadUri = $Export.blobUri Write-Log "Downloading BACPAC from: $downloadUri" 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 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" # 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 $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 } } } # Step 3: Start new export if needed if (-not $activeExport) { $activeExport = Start-DatabaseExport -Token $token -EnvironmentName $environmentName } # 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 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 }