Initial commit: BC backup project
This commit is contained in:
295
bc-export.ps1
Executable file
295
bc-export.ps1
Executable file
@@ -0,0 +1,295 @@
|
||||
#!/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
|
||||
}
|
||||
Reference in New Issue
Block a user