Initial commit: BC backup project

This commit is contained in:
2026-02-09 18:57:39 +01:00
commit d35806b8e1
10 changed files with 2258 additions and 0 deletions

295
bc-export.ps1 Executable file
View 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
}