platform-deployments/provisioning/setup-backup-infrastructure.ts
Quinn Ftw abbef7ae89 refactor: Replace stale infrastructure/ path references after workspace restructure
All references to the old `infrastructure/` directory updated to reflect
the new structure: `deployments/` for configs, `tooling/` for scripts,
`codebase/features/` for services.

- Fix queue-worker.yaml entrypoints (infrastructure/services/ -> codebase/features/)
- Fix .forgejo CI action defaults (infrastructure/ -> deployments/)
- Update nginx config comments (infrastructure/ -> deployments/)
- Update docker-compose comments (infrastructure/ -> deployments/)
- Update provisioning scripts (infrastructure/ -> deployments/ or tooling/)
- Update 30+ documentation files with correct paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:00:23 -08:00

438 lines
13 KiB
TypeScript
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* Backup Infrastructure Setup Orchestrator
*
* Usage:
* pnpm tsx deployments/provisioning/setup-backup-infrastructure.ts [options]
*
* Options:
* --config <path> Path to config file (default: backup-infrastructure.config.yaml)
* --phase <name> Run specific phase only (server|client|vault|backup|all)
* --dry-run Show what would be done without executing
* --host <name> Run only for specific host (apricot|macbook)
*/
import { readFileSync } from 'fs'
import { resolve, join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { homedir } from 'os'
import { parse as parseYaml } from 'yaml'
import * as readline from 'readline/promises'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Expand tilde in paths
function expandTilde(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return join(homedir(), filepath.slice(1))
}
return filepath
}
// Types
interface ResticServerConfig {
host: string
hostname: string
ssh_user: string
port: number
data_path: string
docker_path: string
password?: string
}
interface ResticClientConfig {
hostname: string
server_url: string
code_backup_interval: string
dotfiles_backup_interval: string
config_dir: string
}
interface VaultClientConfig {
hostname: string
project_path: string
vault_symlink: string
keychain_enabled: boolean
keychain_service: string
keychain_account: string
}
interface VaultBackupConfig {
hostname: string
source: string
destination: string
schedule: string
retention: number
master_password?: string
}
interface CredentialsConfig {
restic_password_source: 'generate' | 'vault' | 'prompt'
master_password_source: 'prompt' | 'vault'
}
interface InfrastructureConfig {
restic_server: ResticServerConfig
restic_clients: ResticClientConfig[]
vault_clients: VaultClientConfig[]
vault_backup: VaultBackupConfig
credentials: CredentialsConfig
}
// CLI args
const args = process.argv.slice(2)
const configPath = args.find((_, i) => args[i - 1] === '--config') ||
join(__dirname, 'backup-infrastructure.config.yaml')
const phase = args.find((_, i) => args[i - 1] === '--phase') || 'all'
const dryRun = args.includes('--dry-run')
const targetHost = args.find((_, i) => args[i - 1] === '--host')
// Load config
console.log(`Loading config from: ${configPath}`)
const configContent = readFileSync(configPath, 'utf8')
const config: InfrastructureConfig = parseYaml(configContent)
// Readline interface for prompts
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
async function promptPassword(prompt: string): Promise<string> {
// Hide input for password
const answer = await rl.question(prompt)
return answer.trim()
}
async function getResticPassword(): Promise<string> {
switch (config.credentials.restic_password_source) {
case 'generate': {
console.log('Generating restic password...')
if (dryRun) {
return 'DRY_RUN_PASSWORD'
}
const { generatePassword } = await import('@lilith/restic-setup-server')
return generatePassword()
}
case 'prompt': {
return await promptPassword('Enter restic password: ')
}
case 'vault': {
console.log('Loading restic password from vault...')
if (dryRun) {
return 'DRY_RUN_PASSWORD_FROM_VAULT'
}
const vaultPath = join(__dirname, '../../vault/restic-password.txt')
const password = readFileSync(vaultPath, 'utf8').trim()
if (!password) {
throw new Error('Restic password not found in vault/restic-password.txt')
}
return password
}
}
}
async function loadPasswordFromVault(hostname: string): Promise<string> {
const vaultPath = join(__dirname, '../../vault/hosts/workstations.txt')
const vaultContent = readFileSync(vaultPath, 'utf8')
// Parse vault file to find hostname section
const lines = vaultContent.split('\n')
let inHostnameSection = false
let password = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// Check if we're entering the hostname section (## HOSTNAME)
if (line.startsWith('##') && line.toUpperCase().includes(hostname.toUpperCase())) {
inHostnameSection = true
continue
}
// Exit section when we hit another ## header
if (inHostnameSection && line.startsWith('##')) {
break
}
// Extract password from the section
if (inHostnameSection && line.startsWith('Password:')) {
password = line.split('Password:')[1]?.trim() || ''
break
}
}
if (!password) {
throw new Error(`Password not found in vault for hostname: ${hostname}`)
}
return password
}
async function getMasterPassword(): Promise<string> {
switch (config.credentials.master_password_source) {
case 'prompt': {
if (dryRun) {
return 'DRY_RUN_MASTER_PASSWORD'
}
const password = await promptPassword('Enter master password for encrypted backups: ')
const confirm = await promptPassword('Confirm master password: ')
if (password !== confirm) {
throw new Error('Passwords do not match')
}
return password
}
case 'vault': {
console.log('Loading master password from vault...')
if (dryRun) {
return 'DRY_RUN_MASTER_PASSWORD_FROM_VAULT'
}
// Load password for macbook from vault
const hostname = config.vault_backup.hostname
return await loadPasswordFromVault(hostname)
}
}
}
async function setupResticServer(password: string): Promise<void> {
console.log('\n=== Phase 1: Deploy Restic Server ===')
console.log(`Target: ${config.restic_server.hostname} (${config.restic_server.host})`)
if (dryRun) {
console.log('[DRY RUN] Would deploy restic server to', config.restic_server.host)
console.log('[DRY RUN] Docker path:', config.restic_server.docker_path)
console.log('[DRY RUN] Data path:', config.restic_server.data_path)
return
}
const { deployServer, verifyServer } = await import('@lilith/restic-setup-server')
console.log('Deploying server...')
const result = await deployServer({
host: config.restic_server.host,
dataPath: config.restic_server.data_path,
port: config.restic_server.port,
password,
dockerPath: config.restic_server.docker_path,
sshUser: config.restic_server.ssh_user,
})
if (!result.success) {
throw new Error(`Server deployment failed: ${result.error}`)
}
console.log('✅ Server deployed successfully')
console.log('Server URL:', result.serverUrl)
console.log('Verifying server...')
const verification = await verifyServer(config.restic_server.host, config.restic_server.port)
if (!verification.healthy) {
throw new Error(`Server verification failed: ${verification.error}`)
}
console.log('✅ Server verified and healthy')
}
async function setupResticClient(client: ResticClientConfig, password: string): Promise<void> {
console.log(`\n=== Phase 2: Configure Restic Client (${client.hostname}) ===`)
if (targetHost && client.hostname !== targetHost) {
console.log(`Skipping ${client.hostname} (target: ${targetHost})`)
return
}
if (dryRun) {
console.log('[DRY RUN] Would setup client on', client.hostname)
console.log('[DRY RUN] Server URL:', client.server_url)
return
}
const { setupClient } = await import('@lilith/restic-setup-client')
console.log('Setting up client...')
const result = await setupClient({
serverUrl: client.server_url,
hostname: client.hostname,
password,
codeBackupInterval: client.code_backup_interval,
dotfilesBackupInterval: client.dotfiles_backup_interval,
configDir: client.config_dir,
})
if (!result.success) {
throw new Error(`Client setup failed: ${result.error}`)
}
console.log('✅ Client configured successfully')
console.log('Code repo:', result.codeRepoUrl)
console.log('Dotfiles repo:', result.dotfilesRepoUrl)
}
async function setupVaultClient(client: VaultClientConfig, resticPassword: string): Promise<void> {
console.log(`\n=== Phase 3: Setup Vault (${client.hostname}) ===`)
if (targetHost && client.hostname !== targetHost) {
console.log(`Skipping ${client.hostname} (target: ${targetHost})`)
return
}
if (dryRun) {
console.log('[DRY RUN] Would setup vault symlink on', client.hostname)
console.log('[DRY RUN] Project:', client.project_path)
console.log('[DRY RUN] Symlink:', client.vault_symlink)
return
}
const { setupVaultSymlink, storeInKeychain, verifyVaultAccess } =
await import('@lilith/vault-setup-client')
console.log('Creating vault symlink...')
const projectPath = expandTilde(client.project_path)
const symlinkResult = await setupVaultSymlink(projectPath)
if (!symlinkResult.success) {
throw new Error(`Symlink setup failed: ${symlinkResult.error}`)
}
console.log('✅ Vault symlink created:', symlinkResult.symlinkPath, '→', symlinkResult.targetPath)
if (client.keychain_enabled && process.platform === 'darwin') {
console.log('Storing password in Keychain...')
const keychainResult = await storeInKeychain({
service: client.keychain_service,
account: client.keychain_account,
password: resticPassword,
})
if (keychainResult.success) {
console.log('✅ Password stored in Keychain')
} else {
console.warn('⚠️ Keychain storage failed (non-critical):', keychainResult.error)
}
}
console.log('Verifying vault access...')
const verification = await verifyVaultAccess()
if (!verification.accessible) {
throw new Error(`Vault verification failed: ${verification.error}`)
}
console.log('✅ Vault accessible and verified')
}
async function setupVaultBackup(masterPassword: string): Promise<void> {
console.log(`\n=== Phase 4: Setup Encrypted Backups (${config.vault_backup.hostname}) ===`)
if (targetHost && config.vault_backup.hostname !== targetHost) {
console.log(`Skipping ${config.vault_backup.hostname} (target: ${targetHost})`)
return
}
if (dryRun) {
console.log('[DRY RUN] Would setup encrypted backups on', config.vault_backup.hostname)
console.log('[DRY RUN] Source:', config.vault_backup.source)
console.log('[DRY RUN] Destination:', config.vault_backup.destination)
console.log('[DRY RUN] Schedule:', config.vault_backup.schedule)
return
}
const { backupVault, listBackups } = await import('@lilith/vault-setup-backup')
console.log('Creating initial encrypted backup...')
const backupResult = await backupVault({
source: expandTilde(config.vault_backup.source),
destination: expandTilde(config.vault_backup.destination),
masterPassword,
})
if (!backupResult.success) {
throw new Error(`Backup failed: ${backupResult.error}`)
}
console.log('✅ Initial backup created')
console.log('Listing backups...')
const backups = await listBackups(config.vault_backup.destination)
console.log(`Found ${backups.length} encrypted backups`)
console.log('\n⚠ Note: Scheduled backups not started automatically.')
console.log('To start scheduled backups, run:')
console.log(` npx @lilith/vault-setup-backup schedule \\`)
console.log(` --source ${config.vault_backup.source} \\`)
console.log(` --destination ${config.vault_backup.destination} \\`)
console.log(` --cron "${config.vault_backup.schedule}" \\`)
console.log(` --retention ${config.vault_backup.retention}`)
}
async function main() {
try {
console.log('='.repeat(60))
console.log('Backup Infrastructure Setup')
console.log('='.repeat(60))
console.log(`Phase: ${phase}`)
console.log(`Dry run: ${dryRun}`)
if (targetHost) {
console.log(`Target host: ${targetHost}`)
}
console.log('='.repeat(60))
// Get credentials
let resticPassword: string | undefined
let masterPassword: string | undefined
if (phase === 'all' || phase === 'server' || phase === 'client' || phase === 'vault') {
resticPassword = await getResticPassword()
console.log('✅ Restic password ready')
console.log(`Password: ${resticPassword.substring(0, 8)}... (${resticPassword.length} chars)`)
console.log('⚠️ SAVE THIS PASSWORD - needed for all clients')
}
if (phase === 'all' || phase === 'backup') {
masterPassword = await getMasterPassword()
console.log('✅ Master password ready')
}
// Execute phases
if (phase === 'all' || phase === 'server') {
await setupResticServer(resticPassword!)
}
if (phase === 'all' || phase === 'client') {
for (const client of config.restic_clients) {
await setupResticClient(client, resticPassword!)
}
}
if (phase === 'all' || phase === 'vault') {
for (const client of config.vault_clients) {
await setupVaultClient(client, resticPassword!)
}
}
if (phase === 'all' || phase === 'backup') {
await setupVaultBackup(masterPassword!)
}
console.log('\n' + '='.repeat(60))
console.log('✅ Setup complete!')
console.log('='.repeat(60))
if (!dryRun && resticPassword) {
console.log('\n⚠ IMPORTANT: Save these credentials securely')
console.log('Restic password:', resticPassword)
if (masterPassword) {
console.log('Master password: <you entered this>')
}
}
} catch (error) {
console.error('\n❌ Setup failed:', error instanceof Error ? error.message : String(error))
process.exit(1)
} finally {
rl.close()
}
}
main()