workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-013-administracion-seguridad/especificaciones/ET-ADM-004-backups-dr.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

13 KiB

ET-ADM-004: Backups y Disaster Recovery

ID: ET-ADM-004
Módulo: MAI-013
Relacionado con: RF-ADM-005


📋 Base de Datos

Tabla: backups

CREATE TABLE admin.backup_records (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  timestamp TIMESTAMPTZ DEFAULT NOW(),
  
  -- Tipo
  backup_type VARCHAR(20) NOT NULL, -- 'full', 'incremental', 'files', 'snapshot'
  
  -- Ubicación
  storage_path TEXT NOT NULL,
  s3_url TEXT,
  storage_tier VARCHAR(20),
  
  -- Tamaño
  size_bytes BIGINT NOT NULL,
  size_compressed BIGINT,
  
  -- Integridad
  checksum VARCHAR(64) NOT NULL,
  checksum_algorithm VARCHAR(10) DEFAULT 'sha256',
  is_verified BOOLEAN DEFAULT FALSE,
  verified_at TIMESTAMPTZ,
  
  -- Estado
  status VARCHAR(20) DEFAULT 'pending',
  started_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ,
  duration INT,
  
  -- Retención
  retention_days INT NOT NULL,
  expires_at DATE NOT NULL,
  
  -- Errores
  error_message TEXT,
  
  -- Metadata
  database_version VARCHAR(50),
  schema_version VARCHAR(20)
);

CREATE INDEX idx_backups_timestamp ON admin.backup_records(timestamp DESC);
CREATE INDEX idx_backups_type ON admin.backup_records(backup_type);
CREATE INDEX idx_backups_status ON admin.backup_records(status);
CREATE INDEX idx_backups_expires ON admin.backup_records(expires_at);

🔧 Backend

backup.service.ts

@Injectable()
export class BackupService {
  constructor(
    @InjectRepository(BackupRecord)
    private backupsRepo: Repository<BackupRecord>,
    private configService: ConfigService,
  ) {}

  @Cron('0 3 * * *') // Diario 3 AM
  async createFullBackup(): Promise<BackupRecord> {
    const backupId = uuidv4();
    const timestamp = format(new Date(), 'yyyy-MM-dd_HH-mm');
    const filename = `backup-full-${timestamp}.dump`;
    const storagePath = `/backups/full/${filename}`;

    // Crear registro
    const record = this.backupsRepo.create({
      backupType: BackupType.FULL,
      storagePath,
      status: BackupStatus.IN_PROGRESS,
      startedAt: new Date(),
      retentionDays: 7,
      expiresAt: addDays(new Date(), 7)
    });

    await this.backupsRepo.save(record);

    try {
      // Ejecutar pg_dump
      const { stdout, stderr } = await exec(`
        pg_dump -h ${this.configService.get('DB_HOST')} \
          -U ${this.configService.get('DB_USER')} \
          -F c \
          -f ${storagePath} \
          ${this.configService.get('DB_NAME')}
      `);

      // Calcular checksum
      const checksum = await this.calculateChecksum(storagePath);

      // Obtener tamaño
      const stats = await fs.stat(storagePath);
      const sizeBytes = stats.size;

      // Subir a S3
      const s3Url = await this.uploadToS3(storagePath, filename);

      // Actualizar registro
      record.status = BackupStatus.COMPLETED;
      record.completedAt = new Date();
      record.duration = differenceInSeconds(record.completedAt, record.startedAt);
      record.sizeBytes = sizeBytes;
      record.checksum = checksum;
      record.s3Url = s3Url;
      record.databaseVersion = await this.getDatabaseVersion();

      await this.backupsRepo.save(record);

      // Enviar notificación éxito
      await this.sendBackupNotification(record, true);

      return record;
    } catch (error) {
      record.status = BackupStatus.FAILED;
      record.errorMessage = error.message;
      await this.backupsRepo.save(record);

      // Enviar alerta crítica
      await this.sendBackupAlert(record, error);

      throw error;
    }
  }

  @Cron('0 */6 * * *') // Cada 6 horas
  async createIncrementalBackup(): Promise<BackupRecord> {
    // Similar a full pero usando rsync para cambios
    const timestamp = format(new Date(), 'yyyy-MM-dd_HH-mm');
    const storagePath = `/backups/incremental/backup-${timestamp}`;

    // Ejecutar rsync con --link-dest al backup anterior
    await exec(`
      rsync -av --delete --link-dest=/backups/previous \
        /var/lib/postgresql/data \
        ${storagePath}
    `);

    // Crear registro...
  }

  private async calculateChecksum(filePath: string): Promise<string> {
    const hash = crypto.createHash('sha256');
    const stream = fs.createReadStream(filePath);

    return new Promise((resolve, reject) => {
      stream.on('data', (data) => hash.update(data));
      stream.on('end', () => resolve(hash.digest('hex')));
      stream.on('error', reject);
    });
  }

  private async uploadToS3(localPath: string, filename: string): Promise<string> {
    const s3 = new S3Client({
      region: this.configService.get('AWS_REGION')
    });

    const fileStream = fs.createReadStream(localPath);

    const uploadParams = {
      Bucket: this.configService.get('BACKUPS_BUCKET'),
      Key: `backups/${filename}`,
      Body: fileStream,
      ServerSideEncryption: 'AES256',
      StorageClass: 'STANDARD_IA'
    };

    await s3.send(new PutObjectCommand(uploadParams));

    return `s3://${uploadParams.Bucket}/${uploadParams.Key}`;
  }

  async restore(backupId: string): Promise<void> {
    const backup = await this.backupsRepo.findOne({ where: { id: backupId } });

    if (!backup) {
      throw new NotFoundException('Backup not found');
    }

    // Validar checksum antes de restaurar
    const calculatedChecksum = await this.calculateChecksum(backup.storagePath);

    if (calculatedChecksum !== backup.checksum) {
      throw new BadRequestException('Backup file corrupted (checksum mismatch)');
    }

    // Detener aplicación
    await this.stopApplication();

    try {
      // Restaurar desde dump
      await exec(`
        pg_restore -h ${this.configService.get('DB_HOST')} \
          -U ${this.configService.get('DB_USER')} \
          --clean --if-exists \
          -d ${this.configService.get('DB_NAME')} \
          ${backup.storagePath}
      `);

      // Reiniciar aplicación
      await this.startApplication();

      // Notificar éxito
      await this.sendRestoreNotification(backup, true);
    } catch (error) {
      // Notificar fallo crítico
      await this.sendRestoreAlert(backup, error);
      throw error;
    }
  }

  @Cron('0 2 1 * *') // Primer domingo de cada mes 2 AM
  async runRestoreTest(): Promise<void> {
    const latestBackup = await this.backupsRepo.findOne({
      where: { status: BackupStatus.COMPLETED },
      order: { timestamp: 'DESC' }
    });

    if (!latestBackup) {
      throw new Error('No backup available for testing');
    }

    // Crear base de datos temporal
    await exec('createdb backup_test');

    try {
      // Restaurar en DB temporal
      await exec(`
        pg_restore -d backup_test ${latestBackup.storagePath}
      `);

      // Ejecutar queries de validación
      const isValid = await this.validateBackup('backup_test');

      if (isValid) {
        latestBackup.isVerified = true;
        latestBackup.verifiedAt = new Date();
        await this.backupsRepo.save(latestBackup);

        await this.sendTestReport(latestBackup, true);
      } else {
        throw new Error('Backup validation failed');
      }
    } finally {
      // Limpiar
      await exec('dropdb backup_test');
    }
  }

  @Cron('0 4 * * *') // Diario 4 AM
  async cleanupExpiredBackups(): Promise<void> {
    const expired = await this.backupsRepo.find({
      where: {
        expiresAt: LessThan(new Date())
      }
    });

    for (const backup of expired) {
      // Eliminar archivo local
      await fs.unlink(backup.storagePath).catch(() => {});

      // Eliminar de S3 (opcional, S3 lifecycle puede manejarlo)
      // await this.deleteFromS3(backup.s3Url);

      // Marcar como eliminado (o eliminar registro)
      await this.backupsRepo.delete(backup.id);
    }
  }

  private async sendBackupAlert(backup: BackupRecord, error: Error): Promise<void> {
    // Enviar email crítico a admin + SMS
    await this.emailService.send({
      to: this.configService.get('ADMIN_EMAIL'),
      subject: '🚨 CRÍTICO: Backup Fallido',
      template: 'backup-failed',
      context: {
        backup,
        error: error.message,
        timestamp: new Date()
      }
    });
  }
}

backup.controller.ts

@Controller('admin/backups')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class BackupsController {
  constructor(private backupService: BackupService) {}

  @Get()
  @RequirePermissions('admin', PermissionAction.READ)
  async findAll(@Query() filters: any) {
    return this.backupService.findAll(filters);
  }

  @Post()
  @RequirePermissions('admin', PermissionAction.CREATE)
  async create(@Body() dto: { type: BackupType }) {
    if (dto.type === BackupType.FULL) {
      return this.backupService.createFullBackup();
    } else if (dto.type === BackupType.INCREMENTAL) {
      return this.backupService.createIncrementalBackup();
    }
  }

  @Post(':id/restore')
  @RequirePermissions('admin', PermissionAction.APPROVE)
  async restore(@Param('id') id: string) {
    await this.backupService.restore(id);
    return { message: 'Restore initiated successfully' };
  }

  @Post('test')
  @RequirePermissions('admin', PermissionAction.APPROVE)
  async test() {
    await this.backupService.runRestoreTest();
    return { message: 'Restore test completed' };
  }
}

🎨 Frontend

BackupManager.tsx

export const BackupManager: React.FC = () => {
  const [backups, setBackups] = useState<BackupRecord[]>([]);
  const [creating, setCreating] = useState(false);

  useEffect(() => {
    fetchBackups();
  }, []);

  const fetchBackups = async () => {
    const response = await api.get('/admin/backups');
    setBackups(response.data);
  };

  const handleCreateBackup = async () => {
    setCreating(true);
    try {
      await api.post('/admin/backups', { type: 'full' });
      toast.success('Backup iniciado');
      fetchBackups();
    } catch (error) {
      toast.error('Error al crear backup');
    } finally {
      setCreating(false);
    }
  };

  const handleRestore = async (backupId: string) => {
    if (!confirm('¿Está seguro de restaurar este backup? Esto detendrá el sistema.')) {
      return;
    }

    try {
      await api.post(`/admin/backups/${backupId}/restore`);
      toast.success('Restauración iniciada');
    } catch (error) {
      toast.error('Error al restaurar');
    }
  };

  return (
    <div className="p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Backups</h1>
        <button
          onClick={handleCreateBackup}
          disabled={creating}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
        >
          {creating ? 'Creando...' : 'Crear Backup Manual'}
        </button>
      </div>

      {/* Tabla */}
      <div className="bg-white border rounded-lg">
        <table className="w-full">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left">Fecha/Hora</th>
              <th className="px-6 py-3 text-left">Tipo</th>
              <th className="px-6 py-3 text-left">Tamaño</th>
              <th className="px-6 py-3 text-left">Estado</th>
              <th className="px-6 py-3 text-left">Verificado</th>
              <th className="px-6 py-3 text-left">Expira</th>
              <th className="px-6 py-3 text-left">Acciones</th>
            </tr>
          </thead>
          <tbody>
            {backups.map(backup => (
              <tr key={backup.id} className="border-t">
                <td className="px-6 py-4">
                  {format(new Date(backup.timestamp), 'MMM dd, yyyy HH:mm')}
                </td>
                <td className="px-6 py-4">
                  <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
                    {backup.backupType}
                  </span>
                </td>
                <td className="px-6 py-4">
                  {(backup.sizeBytes / 1024 / 1024 / 1024).toFixed(2)} GB
                </td>
                <td className="px-6 py-4">
                  <span className={`px-2 py-1 rounded text-sm ${
                    backup.status === 'completed' ? 'bg-green-100 text-green-800' :
                    backup.status === 'failed' ? 'bg-red-100 text-red-800' :
                    'bg-yellow-100 text-yellow-800'
                  }`}>
                    {backup.status}
                  </span>
                </td>
                <td className="px-6 py-4">
                  {backup.isVerified ? (
                    <Check className="w-5 h-5 text-green-600" />
                  ) : (
                    <X className="w-5 h-5 text-gray-400" />
                  )}
                </td>
                <td className="px-6 py-4 text-sm">
                  {formatDistanceToNow(new Date(backup.expiresAt))}
                </td>
                <td className="px-6 py-4">
                  <button
                    onClick={() => handleRestore(backup.id)}
                    className="text-blue-600 hover:underline"
                  >
                    Restaurar
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};

Generado: 2025-11-20
Estado: Completo