Skip to main content

Ongoing Synchronization Setup

Overview​

After successful onboarding, the system must continuously synchronize Terraform state changes with the Backstage catalog. This ensures entities remain accurate as infrastructure evolves.

Synchronization Architecture​

Terraform Changes → TFC Webhook → Sync Handler → State Parser → Entity Updater → Backstage Catalog
│
├─→ Scheduled Jobs (backup)
└─→ GitHub Watchers (code changes)

1. Terraform Cloud Webhooks​

Webhook Configuration​

// src/sync/setup/tfc-webhook-setup.ts
export class TfcWebhookSetup {
/**
* Configure TFC webhook for ongoing sync
*/
async setup(
workspaceId: string,
tenantId: string,
businessUnit: string
): Promise<WebhookConfig> {
// Generate secure webhook token
const webhookToken = await this.generateWebhookToken(workspaceId, tenantId);

// Store token for verification
await this.storeWebhookToken(workspaceId, webhookToken);

// Create notification configuration
const webhook = await this.tfcClient.createNotification({
workspaceId,
name: 'backstage-sync',
enabled: true,
destinationType: 'generic',
url: `${process.env.BACKSTAGE_URL}/api/sync/tfc-webhook`,
token: webhookToken,
triggers: [
'run:completed', // Successful applies
'run:errored', // Failed runs (for alerting)
],
});

// Store webhook configuration
await db('sync_webhooks').insert({
workspace_id: workspaceId,
tenant_id: tenantId,
business_unit: businessUnit,
webhook_id: webhook.id,
webhook_url: webhook.url,
enabled: true,
created_at: new Date(),
});

console.log(`TFC webhook configured for workspace ${workspaceId}`);

return {
webhookId: webhook.id,
url: webhook.url,
token: webhookToken,
};
}

private async generateWebhookToken(
workspaceId: string,
tenantId: string
): Promise<string> {
// Generate cryptographically secure token
const token = crypto.randomBytes(32).toString('hex');

// Hash for storage
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex');

return token;
}

private async storeWebhookToken(
workspaceId: string,
token: string
): Promise<void> {
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex');

await db('webhook_tokens').insert({
workspace_id: workspaceId,
token_hash: hashedToken,
created_at: new Date(),
});
}
}

Webhook Handler​

// src/sync/handlers/tfc-webhook-handler.ts
export class TfcWebhookHandler {
/**
* Handle incoming TFC webhook
*/
async handle(req: Request, res: Response): Promise<void> {
// Verify webhook signature
const isValid = await this.verifyWebhook(req);

if (!isValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}

const payload = req.body;

// Extract relevant data
const { run_id, workspace_id, run_status, run_message } = payload;

console.log(`TFC webhook received: workspace=${workspace_id}, run=${run_id}, status=${run_status}`);

// Acknowledge webhook immediately
res.status(202).json({ message: 'Webhook received, processing async' });

// Process webhook asynchronously
this.processWebhookAsync(payload).catch(error => {
console.error('Webhook processing failed:', error);
// Don't throw - webhook already acknowledged
});
}

private async processWebhookAsync(payload: TfcWebhookPayload): Promise<void> {
const { run_id, workspace_id, run_status } = payload;

// Only process successful applies
if (run_status !== 'applied') {
console.log(`Skipping non-applied run: ${run_status}`);
return;
}

// Find workspace configuration
const syncConfig = await db('sync_webhooks')
.where('workspace_id', workspace_id)
.first();

if (!syncConfig) {
console.error(`No sync configuration found for workspace ${workspace_id}`);
return;
}

// Trigger synchronization
await this.syncService.syncWorkspace({
workspaceId: workspace_id,
tenantId: syncConfig.tenant_id,
businessUnit: syncConfig.business_unit,
runId: run_id,
trigger: 'webhook',
});
}

private async verifyWebhook(req: Request): Promise<boolean> {
const signature = req.headers['x-tfc-notification-signature'];
const workspaceId = req.body.workspace_id;

if (!signature || !workspaceId) {
return false;
}

// Retrieve stored token
const tokenRecord = await db('webhook_tokens')
.where('workspace_id', workspaceId)
.first();

if (!tokenRecord) {
return false;
}

// Verify signature
const payload = JSON.stringify(req.body);
const expectedSignature = crypto
.createHmac('sha256', tokenRecord.token_hash)
.update(payload)
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
}

2. Scheduled Synchronization Jobs​

Job Scheduler​

// src/sync/scheduler/sync-job-scheduler.ts
import { CronJob } from 'cron';

export class SyncJobScheduler {
private jobs: Map<string, CronJob> = new Map();

/**
* Schedule periodic sync for workspace
*/
async schedule(
workspaceId: string,
tenantId: string,
businessUnit: string,
options: ScheduleOptions = {}
): Promise<void> {
const {
schedule = '0 */6 * * *', // Every 6 hours by default
enabled = true,
} = options;

// Create cron job
const job = new CronJob(schedule, async () => {
console.log(`Running scheduled sync for workspace ${workspaceId}`);

try {
await this.syncService.syncWorkspace({
workspaceId,
tenantId,
businessUnit,
trigger: 'scheduled',
});

console.log(`Scheduled sync completed for workspace ${workspaceId}`);
} catch (error) {
console.error(`Scheduled sync failed for workspace ${workspaceId}:`, error);

// Alert on repeated failures
await this.checkRepeatedFailures(workspaceId);
}
});

// Store job configuration
await db('sync_schedules').insert({
workspace_id: workspaceId,
tenant_id: tenantId,
business_unit: businessUnit,
schedule,
enabled,
next_run: this.calculateNextRun(schedule),
created_at: new Date(),
});

// Start job if enabled
if (enabled) {
job.start();
this.jobs.set(workspaceId, job);
console.log(`Scheduled sync job for workspace ${workspaceId}: ${schedule}`);
}
}

/**
* Start all scheduled jobs
*/
async startAll(): Promise<void> {
const schedules = await db('sync_schedules')
.where('enabled', true)
.select('*');

for (const schedule of schedules) {
const job = new CronJob(schedule.schedule, async () => {
await this.syncService.syncWorkspace({
workspaceId: schedule.workspace_id,
tenantId: schedule.tenant_id,
businessUnit: schedule.business_unit,
trigger: 'scheduled',
});
});

job.start();
this.jobs.set(schedule.workspace_id, job);
}

console.log(`Started ${schedules.length} scheduled sync jobs`);
}

/**
* Stop scheduled job
*/
async stop(workspaceId: string): Promise<void> {
const job = this.jobs.get(workspaceId);

if (job) {
job.stop();
this.jobs.delete(workspaceId);

await db('sync_schedules')
.where('workspace_id', workspaceId)
.update({ enabled: false });

console.log(`Stopped scheduled sync for workspace ${workspaceId}`);
}
}

private calculateNextRun(schedule: string): Date {
const job = new CronJob(schedule, () => {});
return job.nextDate().toJSDate();
}

private async checkRepeatedFailures(workspaceId: string): Promise<void> {
const recentFailures = await db('sync_history')
.where('workspace_id', workspaceId)
.where('status', 'failed')
.where('created_at', '>', new Date(Date.now() - 86400000)) // Last 24 hours
.count();

if (recentFailures[0].count >= 3) {
await this.alertRepeatedFailures(workspaceId, recentFailures[0].count);
}
}

private async alertRepeatedFailures(workspaceId: string, failureCount: number): Promise<void> {
await slack.postMessage({
channel: '#backstage-alerts',
text: `⚠️ Repeated sync failures for workspace ${workspaceId}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Workspace:* ${workspaceId}\n` +
`*Failures:* ${failureCount} in last 24 hours\n` +
`*Action:* Manual investigation required`,
},
},
],
});
}
}

Sync Schedule Examples​

# Default schedule (every 6 hours)
schedule: "0 */6 * * *"

# Hourly sync (high-activity workspaces)
schedule: "0 * * * *"

# Daily sync (stable workspaces)
schedule: "0 3 * * *" # 3 AM daily

# Weekly sync (archived workspaces)
schedule: "0 2 * * 0" # 2 AM Sunday

3. Synchronization Service​

Core Sync Logic​

// src/sync/service/sync-service.ts
export class SyncService {
/**
* Synchronize workspace with Backstage catalog
*/
async syncWorkspace(params: SyncParams): Promise<SyncResult> {
const { workspaceId, tenantId, businessUnit, runId, trigger } = params;

const syncId = this.generateSyncId();

console.log(`Starting sync ${syncId} for workspace ${workspaceId}`);

try {
// Record sync start
await this.recordSyncStart(syncId, params);

// 1. Fetch current Terraform state
const state = await this.tfcClient.getCurrentState(workspaceId);

// 2. Fetch existing entities
const existingEntities = await this.fetchExistingEntities(tenantId, businessUnit);

// 3. Parse state and generate new entities
const newEntities = await this.stateParser.parse(state, {
tenantId,
businessUnit,
workspaceId,
});

// 4. Calculate diff
const diff = this.calculateDiff(existingEntities, newEntities);

// 5. Apply changes
const changes = await this.applyChanges(diff, tenantId);

// 6. Record sync completion
await this.recordSyncComplete(syncId, changes);

console.log(`Sync ${syncId} completed: +${changes.added} ~${changes.updated} -${changes.deleted}`);

return {
syncId,
status: 'success',
changes,
};
} catch (error) {
console.error(`Sync ${syncId} failed:`, error);

// Record sync failure
await this.recordSyncFailure(syncId, error);

throw error;
}
}

private async fetchExistingEntities(
tenantId: string,
businessUnit: string
): Promise<BackstageEntity[]> {
const entities = await db('entities')
.where('tenant_id', tenantId)
.where('metadata->labels->business-unit', businessUnit)
.select('*');

return entities.map(this.deserializeEntity);
}

private calculateDiff(
existing: BackstageEntity[],
updated: BackstageEntity[]
): EntityDiff {
const existingMap = new Map(existing.map(e => [e.metadata.name, e]));
const updatedMap = new Map(updated.map(e => [e.metadata.name, e]));

const added: BackstageEntity[] = [];
const modified: BackstageEntity[] = [];
const deleted: BackstageEntity[] = [];

// Find added and modified
for (const [name, entity] of updatedMap) {
if (!existingMap.has(name)) {
added.push(entity);
} else {
const existingEntity = existingMap.get(name);
if (!this.areEntitiesEqual(existingEntity, entity)) {
modified.push(entity);
}
}
}

// Find deleted
for (const [name, entity] of existingMap) {
if (!updatedMap.has(name)) {
deleted.push(entity);
}
}

return { added, modified, deleted };
}

private async applyChanges(
diff: EntityDiff,
tenantId: string
): Promise<SyncChanges> {
const changes: SyncChanges = {
added: 0,
updated: 0,
deleted: 0,
};

await db.transaction(async (trx) => {
// Add new entities
for (const entity of diff.added) {
await trx('entities').insert(this.serializeEntity(entity, tenantId));
changes.added++;
}

// Update modified entities
for (const entity of diff.modified) {
await trx('entities')
.where({
'metadata->name': entity.metadata.name,
'metadata->namespace': entity.metadata.namespace,
})
.update({
metadata: JSON.stringify(entity.metadata),
spec: JSON.stringify(entity.spec),
updated_at: new Date(),
});
changes.updated++;
}

// Delete removed entities
for (const entity of diff.deleted) {
await trx('entities')
.where({
'metadata->name': entity.metadata.name,
'metadata->namespace': entity.metadata.namespace,
})
.delete();
changes.deleted++;
}
});

return changes;
}

private areEntitiesEqual(a: BackstageEntity, b: BackstageEntity): boolean {
// Deep comparison (excluding timestamps)
const aClean = { ...a, metadata: { ...a.metadata } };
const bClean = { ...b, metadata: { ...b.metadata } };

delete aClean.metadata.annotations?.['backstage.io/updated-at'];
delete bClean.metadata.annotations?.['backstage.io/updated-at'];

return JSON.stringify(aClean) === JSON.stringify(bClean);
}
}

4. GitHub Repository Watchers​

Code Change Detection​

// src/sync/watchers/github-watcher.ts
export class GitHubRepositoryWatcher {
/**
* Setup GitHub webhook for repository changes
*/
async setupWebhook(
owner: string,
repo: string,
tenantId: string
): Promise<void> {
const webhookUrl = `${process.env.BACKSTAGE_URL}/api/sync/github-webhook`;
const webhookSecret = await this.generateWebhookSecret(owner, repo);

// Store secret
await this.storeWebhookSecret(owner, repo, webhookSecret);

// Create webhook
await this.githubClient.repos.createWebhook({
owner,
repo,
config: {
url: webhookUrl,
content_type: 'json',
secret: webhookSecret,
},
events: [
'push', // Code changes
'repository', // Repository metadata changes
],
});

console.log(`GitHub webhook configured for ${owner}/${repo}`);
}

/**
* Handle GitHub webhook
*/
async handleWebhook(req: Request, res: Response): Promise<void> {
// Verify signature
const isValid = await this.verifyGitHubSignature(req);

if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}

const event = req.headers['x-github-event'];
const payload = req.body;

// Acknowledge immediately
res.status(202).json({ message: 'Webhook received' });

// Process asynchronously
if (event === 'push') {
await this.handlePushEvent(payload);
} else if (event === 'repository') {
await this.handleRepositoryEvent(payload);
}
}

private async handlePushEvent(payload: GitHubPushPayload): Promise<void> {
const { repository, commits } = payload;

// Check if .backstage/config.yaml was modified
const configModified = commits.some(commit =>
commit.modified?.includes('.backstage/config.yaml') ||
commit.added?.includes('.backstage/config.yaml')
);

if (configModified) {
console.log(`Backstage config modified in ${repository.full_name}`);

// Trigger re-sync
await this.syncService.syncRepository({
repoUrl: repository.html_url,
trigger: 'config_change',
});
}
}

private async handleRepositoryEvent(payload: GitHubRepositoryPayload): Promise<void> {
const { action, repository } = payload;

if (action === 'edited') {
console.log(`Repository metadata changed: ${repository.full_name}`);

// Update entity metadata (description, topics, etc.)
await this.updateEntityMetadata(repository);
}
}
}

5. Drift Detection​

State Drift Monitor​

// src/sync/drift/drift-detector.ts
export class DriftDetector {
/**
* Detect drift between Terraform state and Backstage catalog
*/
async detectDrift(workspaceId: string, tenantId: string): Promise<DriftReport> {
// Fetch Terraform state
const tfState = await this.tfcClient.getCurrentState(workspaceId);

// Fetch Backstage entities
const backstageEntities = await this.fetchEntities(workspaceId, tenantId);

// Parse state to entities
const stateEntities = await this.stateParser.parse(tfState, { tenantId });

// Compare
const drift = this.compareSets(stateEntities, backstageEntities);

if (drift.hasDrift) {
console.warn(`Drift detected for workspace ${workspaceId}`);

// Notify
await this.notifyDrift(workspaceId, drift);

// Auto-reconcile if configured
if (await this.shouldAutoReconcile(workspaceId)) {
await this.reconcile(workspaceId, tenantId, drift);
}
}

return drift;
}

private compareSets(
stateEntities: BackstageEntity[],
catalogEntities: BackstageEntity[]
): DriftReport {
const stateMap = new Map(stateEntities.map(e => [e.metadata.name, e]));
const catalogMap = new Map(catalogEntities.map(e => [e.metadata.name, e]));

const missing: string[] = []; // In state but not in catalog
const extra: string[] = []; // In catalog but not in state
const diverged: Array<{ name: string; diff: object }> = [];

// Check for missing and diverged
for (const [name, stateEntity] of stateMap) {
const catalogEntity = catalogMap.get(name);

if (!catalogEntity) {
missing.push(name);
} else if (!this.areEntitiesEqual(stateEntity, catalogEntity)) {
diverged.push({
name,
diff: this.calculateDiff(stateEntity, catalogEntity),
});
}
}

// Check for extra
for (const name of catalogMap.keys()) {
if (!stateMap.has(name)) {
extra.push(name);
}
}

return {
hasDrift: missing.length > 0 || extra.length > 0 || diverged.length > 0,
missing,
extra,
diverged,
};
}

private async reconcile(
workspaceId: string,
tenantId: string,
drift: DriftReport
): Promise<void> {
console.log(`Auto-reconciling drift for workspace ${workspaceId}`);

await this.syncService.syncWorkspace({
workspaceId,
tenantId,
trigger: 'drift_reconciliation',
});
}
}

6. Sync Monitoring & Observability​

Sync Metrics​

// src/sync/monitoring/sync-metrics.ts
export class SyncMetrics {
recordSyncDuration(workspaceId: string, durationMs: number): void {
metrics.histogram('sync.duration_ms', durationMs, {
workspace_id: workspaceId,
});
}

recordSyncSuccess(workspaceId: string, changes: SyncChanges): void {
metrics.counter('sync.success', 1, { workspace_id: workspaceId });
metrics.gauge('sync.changes.added', changes.added, { workspace_id: workspaceId });
metrics.gauge('sync.changes.updated', changes.updated, { workspace_id: workspaceId });
metrics.gauge('sync.changes.deleted', changes.deleted, { workspace_id: workspaceId });
}

recordSyncFailure(workspaceId: string, error: Error): void {
metrics.counter('sync.failure', 1, {
workspace_id: workspaceId,
error_type: error.constructor.name,
});
}

recordDriftDetected(workspaceId: string, drift: DriftReport): void {
metrics.gauge('sync.drift.missing', drift.missing.length, { workspace_id: workspaceId });
metrics.gauge('sync.drift.extra', drift.extra.length, { workspace_id: workspaceId });
metrics.gauge('sync.drift.diverged', drift.diverged.length, { workspace_id: workspaceId });
}
}

Sync Dashboard​

# Grafana dashboard
Dashboard: Backstage Synchronization
Panels:
- Sync Success Rate (24h)
- Sync Duration (p50, p95, p99)
- Entity Changes (added, updated, deleted)
- Drift Detection
- Webhook Delivery Rate
- Scheduled Job Runs
- Error Rate by Workspace

7. Configuration Management​

Sync Configuration​

# config/sync-config.yaml
synchronization:
webhooks:
tfc:
enabled: true
url: "${BACKSTAGE_URL}/api/sync/tfc-webhook"
timeout: 5000
github:
enabled: true
url: "${BACKSTAGE_URL}/api/sync/github-webhook"
events: [push, repository]

scheduled:
enabled: true
default_schedule: "0 */6 * * *" # Every 6 hours
max_concurrent: 5

drift:
detection_enabled: true
auto_reconcile: true
check_interval: "0 */12 * * *" # Every 12 hours

retry:
max_attempts: 3
backoff: exponential
base_delay: 2000

notifications:
slack:
enabled: true
channel: "#backstage-sync"
notify_on_failure: true
notify_on_drift: true

Summary​

Sync Mechanisms​

MechanismTriggerLatencyUse Case
TFC WebhookTerraform apply< 1sReal-time state changes
GitHub WebhookCode push< 1sConfig file changes
Scheduled JobCron scheduleHoursBackup sync, drift detection
Manual TriggerAPI/UIImmediateOn-demand refresh

Reliability Features​

✅ Real-time synchronization (webhooks) ✅ Backup scheduled sync (cron jobs) ✅ Drift detection (automated reconciliation) ✅ Retry with backoff (automatic recovery) ✅ Comprehensive monitoring (metrics & alerts) ✅ Idempotent operations (safe to retry)

Next Steps​

See 09-implementation-guide.md for step-by-step implementation instructions.