Files
BC-bak/bc-export.ps1
Malin 3bad3ad171 feat: add incremental backups, S3 cleanup, and cron scheduling
Incremental backups using BC API's lastModifiedDateTime filter to only
export records changed since the last successful run. Runs every 15
minutes via cron, with a daily full backup for complete snapshots.

bc-export.ps1:
- Add -SinceDateTime parameter for incremental filtering
- Append $filter=lastModifiedDateTime gt {timestamp} to all entity URLs
- Exit code 2 when no records changed (skip archive/upload)
- Record mode and sinceDateTime in export-metadata.json

bc-backup.sh:
- Accept --mode full|incremental flag (default: incremental)
- State file (last-run-state.json) tracks last successful run timestamp
- Auto-fallback to full when no state file exists
- Skip archive/encrypt/upload when incremental finds 0 changes
- Lock file (.backup.lock) prevents overlapping cron runs
- S3 keys organized by mode: backups/full/ vs backups/incremental/

bc-cleanup.sh (new):
- Lists all S3 objects under backups/ prefix
- Deletes objects older than RETENTION_DAYS (default 30)
- Handles pagination for large buckets
- Gracefully handles COMPLIANCE-locked objects

bc-backup.conf.template:
- Add BACKUP_MODE_DEFAULT option

cron-examples.txt:
- Recommended setup: 15-min incremental + daily full + daily cleanup
- Alternative schedules (30-min, hourly)
- Systemd timer examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:22:08 +01:00

482 lines
17 KiB
PowerShell
Executable File

#!/usr/bin/env pwsh
#
# Business Central Data Export via BC API v2.0
# Authenticates to Azure AD and extracts critical business data as JSON
#
param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[string]$SinceDateTime = "" # ISO 8601, e.g. "2026-02-15T00:00:00Z" for incremental
)
# 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
$bcCompanyName = $env:BC_COMPANY_NAME # optional: filter to specific company
$baseUrl = "https://api.businesscentral.dynamics.com/v2.0/$tenantId/$environmentName/api/v2.0"
# Standalone entities to extract
$entities = @(
"accounts",
"customers",
"vendors",
"items",
"generalLedgerEntries",
"bankAccounts",
"employees",
"dimensions",
"dimensionValues",
"currencies",
"paymentTerms",
"paymentMethods",
"journals",
"countriesRegions"
)
# Document entities with line items
# Lines cannot be queried standalone at the top level.
# We fetch document headers first, then fetch lines per document.
$documentEntities = @{
"salesInvoices" = "salesInvoiceLines"
"salesOrders" = "salesOrderLines"
"salesCreditMemos" = "salesCreditMemoLines"
"purchaseInvoices" = "purchaseInvoiceLines"
"purchaseOrders" = "purchaseOrderLines"
}
# Token management
$script:currentToken = $null
$script:tokenExpiry = [datetime]::MinValue
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 {
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"
$script:currentToken = $response.access_token
# Refresh 5 minutes before actual expiry (tokens typically last 60-90 min)
$script:tokenExpiry = (Get-Date).AddSeconds($response.expires_in - 300)
Write-Log "Successfully authenticated (token valid for $($response.expires_in)s)"
return $script:currentToken
}
catch {
Write-Log "Failed to authenticate: $_" "ERROR"
throw
}
}
function Get-ValidToken {
if ($null -eq $script:currentToken -or (Get-Date) -ge $script:tokenExpiry) {
Write-Log "Token expired or missing, refreshing..."
Get-AzureADToken | Out-Null
}
return $script:currentToken
}
function Invoke-BCApi {
param(
[string]$Url,
[int]$TimeoutSec = 120,
[int]$MaxRetries = 10
)
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
$token = Get-ValidToken
$headers = @{
"Authorization" = "Bearer $token"
"Accept" = "application/json"
}
try {
$response = Invoke-RestMethod -Uri $Url -Method Get -Headers $headers -TimeoutSec $TimeoutSec
return $response
}
catch {
$statusCode = $null
$errorBody = ""
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
if ($_.ErrorDetails.Message) {
$errorBody = $_.ErrorDetails.Message
}
# Table lock: BC returns 500 with "being updated in a transaction"
$isTableLock = $errorBody -match "transaction done by another session|being updated in a transaction|deadlock|locked"
# Rate limit
$isThrottled = ($statusCode -eq 429)
# Server error
$isServerError = ($statusCode -ge 500 -and -not $isTableLock)
# Timeout
$isTimeout = ($_ -match "Timeout")
$isRetryable = $isTableLock -or $isThrottled -or $isServerError -or $isTimeout
if ($isRetryable -and $attempt -lt $MaxRetries) {
if ($isTableLock) {
# Table locks can last a while - wait longer with jitter
$wait = [math]::Min(30 + ($attempt * 15), 120)
Write-Log " Table lock detected (attempt $attempt/$MaxRetries), waiting ${wait}s..." "WARN"
}
elseif ($isThrottled) {
$wait = [math]::Min(30 * $attempt, 300)
Write-Log " Rate limited (attempt $attempt/$MaxRetries), waiting ${wait}s..." "WARN"
}
else {
$wait = [math]::Min(10 * $attempt, 120)
Write-Log " Request failed (attempt $attempt/$MaxRetries), retrying in ${wait}s..." "WARN"
}
Start-Sleep -Seconds $wait
continue
}
throw
}
}
}
function Get-BCData {
param(
[string]$Url
)
$allRecords = @()
$currentUrl = $Url
while ($currentUrl) {
$response = Invoke-BCApi -Url $currentUrl
if ($response.value) {
$allRecords += $response.value
}
$currentUrl = $response.'@odata.nextLink'
}
return $allRecords
}
function Get-Companies {
Write-Log "Fetching companies..."
$companiesUrl = "$baseUrl/companies"
$companies = Get-BCData -Url $companiesUrl
Write-Log "Found $($companies.Count) company/companies"
return $companies
}
function Export-EntityData {
param(
[string]$CompanyId,
[string]$CompanyName,
[string]$EntityName,
[string]$OutputDir
)
$entityUrl = "$baseUrl/companies($CompanyId)/$EntityName"
if ($SinceDateTime) {
$entityUrl += "?`$filter=lastModifiedDateTime gt $SinceDateTime"
}
$maxEntityRetries = 5
for ($entityAttempt = 1; $entityAttempt -le $maxEntityRetries; $entityAttempt++) {
Write-Log " Exporting $EntityName..."
try {
$data = Get-BCData -Url $entityUrl
$count = 0
if ($data) { $count = $data.Count }
$outputFile = Join-Path $OutputDir "$EntityName.json"
$data | ConvertTo-Json -Depth 10 | Out-File -FilePath $outputFile -Encoding utf8
Write-Log " $EntityName : $count records"
return $count
}
catch {
$errorMsg = "$_"
$isTableLock = $errorMsg -match "transaction done by another session|being updated in a transaction|deadlock|locked"
if ($isTableLock -and $entityAttempt -lt $maxEntityRetries) {
$wait = 60 * $entityAttempt
Write-Log " Table lock on $EntityName (attempt $entityAttempt/$maxEntityRetries), restarting in ${wait}s..." "WARN"
Start-Sleep -Seconds $wait
continue
}
Write-Log " Failed to export ${EntityName}: $errorMsg" "WARN"
$outputFile = Join-Path $OutputDir "$EntityName.json"
"[]" | Out-File -FilePath $outputFile -Encoding utf8
return 0
}
}
return 0
}
function Export-DocumentWithLines {
param(
[string]$CompanyId,
[string]$CompanyName,
[string]$DocumentEntity,
[string]$LineEntity,
[string]$OutputDir
)
# Retry the entire entity export if it fails (e.g. table lock on first page)
$maxEntityRetries = 5
for ($entityAttempt = 1; $entityAttempt -le $maxEntityRetries; $entityAttempt++) {
Write-Log " Exporting $DocumentEntity (headers + lines)..."
$docFile = Join-Path $OutputDir "$DocumentEntity.jsonl"
$lineFile = Join-Path $OutputDir "$LineEntity.jsonl"
[System.IO.File]::WriteAllText($docFile, "")
[System.IO.File]::WriteAllText($lineFile, "")
$docCount = 0
$lineCount = 0
$failed = $false
try {
# Step 1: Fetch document headers page by page (no $expand)
# BC API default page size is ~100, with @odata.nextLink for more
$currentUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity"
if ($SinceDateTime) {
$currentUrl += "?`$filter=lastModifiedDateTime gt $SinceDateTime"
}
while ($currentUrl) {
$response = Invoke-BCApi -Url $currentUrl
if (-not $response.value -or $response.value.Count -eq 0) {
break
}
# Step 2: For each document in this page, fetch its lines
foreach ($doc in $response.value) {
$docCount++
$docId = $doc.id
# Write document header to disk
$jsonLine = $doc | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($docFile, $jsonLine + "`n")
# Fetch lines for this document
$linesUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity($docId)/$LineEntity"
try {
$linesResponse = Invoke-BCApi -Url $linesUrl -TimeoutSec 60
if ($linesResponse.value -and $linesResponse.value.Count -gt 0) {
foreach ($line in $linesResponse.value) {
$lineCount++
$lineJson = $line | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($lineFile, $lineJson + "`n")
}
# Handle pagination within lines (unlikely but possible)
$nextLinesUrl = $linesResponse.'@odata.nextLink'
while ($nextLinesUrl) {
$moreLinesResponse = Invoke-BCApi -Url $nextLinesUrl -TimeoutSec 60
if ($moreLinesResponse.value) {
foreach ($line in $moreLinesResponse.value) {
$lineCount++
$lineJson = $line | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($lineFile, $lineJson + "`n")
}
}
$nextLinesUrl = $moreLinesResponse.'@odata.nextLink'
}
}
}
catch {
Write-Log " Warning: failed to fetch lines for $DocumentEntity $docId : $_" "WARN"
}
# Progress every 100 documents
if ($docCount % 100 -eq 0) {
Write-Log " Progress: $docCount documents, $lineCount lines"
}
}
# Next page of documents
$currentUrl = $response.'@odata.nextLink'
}
Write-Log " $DocumentEntity : $docCount documents, $lineCount lines (complete)"
return ($docCount + $lineCount)
}
catch {
$errorMsg = "$_"
$isTableLock = $errorMsg -match "transaction done by another session|being updated in a transaction|deadlock|locked"
if ($isTableLock -and $entityAttempt -lt $maxEntityRetries) {
$wait = 60 * $entityAttempt
Write-Log " Table lock on $DocumentEntity (attempt $entityAttempt/$maxEntityRetries), restarting in ${wait}s..." "WARN"
Start-Sleep -Seconds $wait
continue
}
Write-Log " Failed to export ${DocumentEntity} at doc #$docCount : $errorMsg" "WARN"
Write-Log " Partial data saved ($docCount docs, $lineCount lines)" "WARN"
return ($docCount + $lineCount)
}
}
return 0
}
# Main execution
try {
$exportMode = if ($SinceDateTime) { "incremental" } else { "full" }
Write-Log "========================================="
Write-Log "BC Data Export Script (API v2.0)"
Write-Log "========================================="
Write-Log "Environment: $environmentName"
Write-Log "Mode: $exportMode"
if ($SinceDateTime) {
Write-Log "Changes since: $SinceDateTime"
}
Write-Log "Output Path: $OutputPath"
Write-Log "Entities to extract: $($entities.Count + $documentEntities.Count) ($($documentEntities.Count) with line items)"
# Create output directory
$exportDir = $OutputPath
if (-not (Test-Path $exportDir)) {
New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
}
# Step 1: Get Azure AD token
Get-AzureADToken | Out-Null
# Step 2: Get companies
$companies = Get-Companies
if ($companies.Count -eq 0) {
Write-Log "No companies found in environment $environmentName" "ERROR"
exit 1
}
# Save companies list
$companies | ConvertTo-Json -Depth 10 | Out-File -FilePath (Join-Path $exportDir "companies.json") -Encoding utf8
# Filter to specific company if configured
$targetCompanies = $companies
if ($bcCompanyName) {
$targetCompanies = $companies | Where-Object { $_.name -eq $bcCompanyName -or $_.displayName -eq $bcCompanyName }
if ($targetCompanies.Count -eq 0) {
Write-Log "Company '$bcCompanyName' not found. Available: $($companies.name -join ', ')" "ERROR"
exit 1
}
Write-Log "Filtering to company: $bcCompanyName"
}
$totalRecords = 0
$totalEntities = 0
$failedEntities = @()
# Step 3: Export data for each company
foreach ($company in $targetCompanies) {
$companyName = $company.name
$companyId = $company.id
Write-Log "-----------------------------------------"
Write-Log "Exporting company: $companyName ($companyId)"
# Create company directory (sanitize name for filesystem)
$safeName = $companyName -replace '[\\/:*?"<>|]', '_'
$companyDir = Join-Path $exportDir $safeName
if (-not (Test-Path $companyDir)) {
New-Item -ItemType Directory -Path $companyDir -Force | Out-Null
}
# Export standalone entities
foreach ($entity in $entities) {
$count = Export-EntityData `
-CompanyId $companyId `
-CompanyName $companyName `
-EntityName $entity `
-OutputDir $companyDir
$totalRecords += $count
$totalEntities++
if ($count -eq 0) {
$failedEntities += "$companyName/$entity"
}
}
# Export document entities with their line items
foreach ($docEntity in $documentEntities.Keys) {
$lineEntity = $documentEntities[$docEntity]
$count = Export-DocumentWithLines `
-CompanyId $companyId `
-CompanyName $companyName `
-DocumentEntity $docEntity `
-LineEntity $lineEntity `
-OutputDir $companyDir
$totalRecords += $count
$totalEntities++
if ($count -eq 0) {
$failedEntities += "$companyName/$docEntity"
}
}
}
# Save export metadata
$metadata = @{
exportDate = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" -AsUTC)
environment = $environmentName
mode = $exportMode
sinceDateTime = if ($SinceDateTime) { $SinceDateTime } else { $null }
companies = @($targetCompanies | ForEach-Object { $_.name })
entitiesExported = $totalEntities
totalRecords = $totalRecords
failedEntities = $failedEntities
}
$metadata | ConvertTo-Json -Depth 5 | Out-File -FilePath (Join-Path $exportDir "export-metadata.json") -Encoding utf8
Write-Log "========================================="
Write-Log "Export completed"
Write-Log "Mode: $exportMode"
Write-Log "Companies: $($targetCompanies.Count)"
Write-Log "Entities: $totalEntities"
Write-Log "Total records: $totalRecords"
if ($failedEntities.Count -gt 0) {
Write-Log "Failed/empty: $($failedEntities.Count) entities" "WARN"
}
Write-Log "========================================="
# Exit code 2 = success but no records (used by bc-backup.sh to skip empty incrementals)
if ($totalRecords -eq 0 -and $exportMode -eq "incremental") {
Write-Log "No changes detected since $SinceDateTime"
exit 2
}
exit 0
}
catch {
Write-Log "Unexpected error: $_" "ERROR"
Write-Log "Stack trace: $($_.ScriptStackTrace)" "ERROR"
exit 1
}