The Admin Center export API requires an Azure Storage SAS URI which requires an Azure Subscription - defeating the purpose of an independent backup. Instead, use BC API v2.0 to extract critical business data (customers, vendors, items, GL entries, invoices, etc.) as JSON files. - bc-export.ps1: rewritten to use BC API v2.0 endpoints, extracts 23 entity types per company with OData pagination support - bc-backup.sh: handles JSON export directory, creates tar.gz archive before encrypting and uploading to S3 - bc-backup.conf.template: removed Azure Storage SAS config, added optional BC_COMPANY_NAME filter - decrypt-backup.sh: updated for tar.gz.gpg format, shows extracted entity files and metadata after decryption Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
7.5 KiB
PowerShell
Executable File
264 lines
7.5 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
|
|
)
|
|
|
|
# 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"
|
|
|
|
# Entities to extract - critical business data
|
|
$entities = @(
|
|
"accounts",
|
|
"customers",
|
|
"vendors",
|
|
"items",
|
|
"salesInvoices",
|
|
"salesInvoiceLines",
|
|
"salesOrders",
|
|
"salesOrderLines",
|
|
"salesCreditMemos",
|
|
"salesCreditMemoLines",
|
|
"purchaseInvoices",
|
|
"purchaseInvoiceLines",
|
|
"purchaseOrders",
|
|
"purchaseOrderLines",
|
|
"generalLedgerEntries",
|
|
"bankAccounts",
|
|
"employees",
|
|
"dimensions",
|
|
"dimensionValues",
|
|
"currencies",
|
|
"paymentTerms",
|
|
"paymentMethods",
|
|
"journals",
|
|
"countriesRegions"
|
|
)
|
|
|
|
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-BCData {
|
|
param(
|
|
[string]$Token,
|
|
[string]$Url
|
|
)
|
|
|
|
$headers = @{
|
|
"Authorization" = "Bearer $Token"
|
|
"Accept" = "application/json"
|
|
}
|
|
|
|
$allRecords = @()
|
|
|
|
$currentUrl = $Url
|
|
while ($currentUrl) {
|
|
try {
|
|
$response = Invoke-RestMethod -Uri $currentUrl -Method Get -Headers $headers
|
|
}
|
|
catch {
|
|
Write-Log "API request failed for $currentUrl : $_" "ERROR"
|
|
throw
|
|
}
|
|
|
|
if ($response.value) {
|
|
$allRecords += $response.value
|
|
}
|
|
|
|
# Handle OData pagination
|
|
$currentUrl = $response.'@odata.nextLink'
|
|
}
|
|
|
|
return $allRecords
|
|
}
|
|
|
|
function Get-Companies {
|
|
param([string]$Token)
|
|
|
|
Write-Log "Fetching companies..."
|
|
$companiesUrl = "$baseUrl/companies"
|
|
$companies = Get-BCData -Token $Token -Url $companiesUrl
|
|
Write-Log "Found $($companies.Count) company/companies"
|
|
return $companies
|
|
}
|
|
|
|
function Export-EntityData {
|
|
param(
|
|
[string]$Token,
|
|
[string]$CompanyId,
|
|
[string]$CompanyName,
|
|
[string]$EntityName,
|
|
[string]$OutputDir
|
|
)
|
|
|
|
$entityUrl = "$baseUrl/companies($CompanyId)/$EntityName"
|
|
|
|
Write-Log " Exporting $EntityName..."
|
|
|
|
try {
|
|
$data = Get-BCData -Token $Token -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 {
|
|
Write-Log " Failed to export ${EntityName}: $_" "WARN"
|
|
# Write empty array so downstream knows it was attempted
|
|
$outputFile = Join-Path $OutputDir "$EntityName.json"
|
|
"[]" | Out-File -FilePath $outputFile -Encoding utf8
|
|
return 0
|
|
}
|
|
}
|
|
|
|
# Main execution
|
|
try {
|
|
Write-Log "========================================="
|
|
Write-Log "BC Data Export Script (API v2.0)"
|
|
Write-Log "========================================="
|
|
Write-Log "Environment: $environmentName"
|
|
Write-Log "Output Path: $OutputPath"
|
|
Write-Log "Entities to extract: $($entities.Count)"
|
|
|
|
# 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
|
|
$token = Get-AzureADToken -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
|
|
|
|
# Step 2: Get companies
|
|
$companies = Get-Companies -Token $token
|
|
|
|
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
|
|
}
|
|
|
|
foreach ($entity in $entities) {
|
|
$count = Export-EntityData `
|
|
-Token $token `
|
|
-CompanyId $companyId `
|
|
-CompanyName $companyName `
|
|
-EntityName $entity `
|
|
-OutputDir $companyDir
|
|
|
|
$totalRecords += $count
|
|
$totalEntities++
|
|
|
|
if ($count -eq 0) {
|
|
$failedEntities += "$companyName/$entity"
|
|
}
|
|
}
|
|
}
|
|
|
|
# Save export metadata
|
|
$metadata = @{
|
|
exportDate = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" -AsUTC)
|
|
environment = $environmentName
|
|
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 "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 0
|
|
}
|
|
catch {
|
|
Write-Log "Unexpected error: $_" "ERROR"
|
|
Write-Log "Stack trace: $($_.ScriptStackTrace)" "ERROR"
|
|
exit 1
|
|
}
|