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>
438 lines
13 KiB
TypeScript
Executable file
438 lines
13 KiB
TypeScript
Executable file
#!/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()
|