Crea archivos tar de logs de CI con Node.js y tar-stream
Usa tar-stream para escribir logs de CI completos en un archivo tar a medida que
llegan. El siguiente ejemplo transmite el archivo tar a un archivo temporal y publica
ci-logs.tar solo después de que todas las entradas hayan terminado. Si el destino ya
existe, se deja intacto, incluso cuando falla un productor de logs.
Este es un flujo de trabajo local de Node.js para logs proporcionados como cadenas o Buffers.
Cada log cabe en memoria; el archivo tar combinado no tiene por qué caber. Genera un archivo
.tar sin comprimir en disco, no un archivo tar en memoria ni una transmisión
en tiempo real de un log sin terminar.
Dependencias
Usa Node.js 24.15.0 o una versión posterior de la rama 24.x, Corepack con Yarn 4.12.0 disponible, Bash y GNU tar para la inspección. Este tutorial se probó en Linux con Node.js 24.15.0 y GNU tar 1.35. Usa un sistema de archivos local que admita enlaces duros y un directorio de destino que controles. Los sistemas de archivos de red y Windows quedan fuera del alcance probado en este tutorial.
Pega lo siguiente en Bash desde el directorio padre donde quieras crear el proyecto de ejemplo.
El subshell mantiene tu directorio actual sin cambios, y && detiene la
configuración si falla un paso. Si node-tar-demo ya existe, elige otro directorio
padre en lugar de eliminar un proyecto existente.
(
mkdir node-tar-demo &&
cd node-tar-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact tar-stream@3.1.7
)
El proyecto habilita explícitamente los módulos ES e instala node_modules
convencional para el comando node. Su propio archivo de bloqueo evita que
Yarn lo trate como parte de un proyecto contenedor. Node ejecuta estos archivos TypeScript mediante
la eliminación nativa de tipos;
esto ejecuta el código sin verificar sus tipos.
Transmite las entradas y publica el archivo tar terminado
Guarda este módulo completo como node-tar-demo/archive.ts. Un productor recibe un
AbortSignal y entrega un log completo a la vez. Empieza a consumir el stream de
empaquetado antes de añadir entradas y espera el callback de cada entrada antes de solicitar otro
log. Estas son las operaciones de empaquetado descritas en la
documentación de tar-stream.
import type { Writable } from 'node:stream'
import { createWriteStream } from 'node:fs'
import { link, mkdtemp, rm } from 'node:fs/promises'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import tar from 'tar-stream'
export interface LogEntry {
filename: string
content: string | Buffer
}
type LogProducer = (signal: AbortSignal) => Iterable<LogEntry> | AsyncIterable<LogEntry>
interface ArchiveOptions {
signal?: AbortSignal
}
function validateFilename(filename: string): string {
if (
!filename || filename.includes('\0') ||
path.posix.isAbsolute(filename) || path.win32.isAbsolute(filename) ||
filename.includes(':') || filename.split(/[\\/]/).some((part) => !part || part === '.' || part === '..')
) {
throw new Error('Invalid archive filename')
}
return filename.replaceAll('\\', '/')
}
async function addLogToArchive(
pack: ReturnType<typeof tar.pack>, filename: string, content: string | Buffer,
): Promise<void> {
return new Promise((resolve, reject) => {
pack.entry({ name: validateFilename(filename), mode: 0o600 }, content, (error) => {
if (error) reject(error)
else resolve()
}).on('error', reject)
})
}
export async function createLogArchive(
getLogs: LogProducer, output: Writable, options: ArchiveOptions = {},
): Promise<void> {
const controller = new AbortController()
const signal = options.signal
? AbortSignal.any([options.signal, controller.signal])
: controller.signal
const pack = tar.pack()
const completed = pipeline(pack, output, { signal })
const producing = (async () => {
signal.throwIfAborted()
for await (const log of getLogs(signal)) {
signal.throwIfAborted()
await addLogToArchive(pack, log.filename, log.content)
}
signal.throwIfAborted()
pack.finalize()
})()
try {
await Promise.all([completed, producing])
} catch (error) {
// Stop both sides, then wait for the producer and file handle to finish cleanup.
controller.abort(error)
await Promise.allSettled([completed, producing])
throw error
}
}
export async function saveLogArchive(
getLogs: LogProducer, destination: string, options: ArchiveOptions = {},
): Promise<void> {
options.signal?.throwIfAborted()
const target = path.resolve(destination)
const staging = await mkdtemp(path.join(path.dirname(target), '.ci-logs-'))
const temporary = path.join(staging, 'archive.tar')
try {
await createLogArchive(
getLogs,
createWriteStream(temporary, { flags: 'wx', mode: 0o600 }),
options,
)
options.signal?.throwIfAborted()
await link(temporary, target)
} finally {
await rm(staging, { recursive: true, force: true })
}
}
La función auxiliar de transmisión coordina la producción y la escritura.
pipeline() de Node
gestiona la contrapresión y destruye los streams conectados al cancelarse la operación. Si falla
cualquiera de las partes, la función auxiliar también cancela el productor y espera a que ambas
tareas se resuelvan, ya sea con éxito o con error. Tu productor debe pasar la señal a cualquier
operación que pueda esperar, como hace el temporizador de la demostración que aparece a continuación.
La cancelación no puede detener una promesa arbitraria que ignore la señal.
La función auxiliar de guardado usa un enlace duro
para dar al archivo completo su nombre final.
link(2) de Linux
rechaza un destino existente, incluso si es un enlace simbólico. No hay una comprobación de
existencia separada seguida de un cambio de nombre que sobrescriba el destino. El directorio
temporal está junto al destino para que ambos nombres estén en el mismo sistema de archivos.
Eliminar el nombre temporal después de la publicación deja intacto el archivo tar final.
Ejecuta e inspecciona el ejemplo
Guarda lo siguiente como node-tar-demo/demo.ts. El temporizador simula la espera hasta que
se complete otro paso de CI; sustituye ciLogs por tu propio productor al
integrarlo. La demostración incluye una entrada vacía y un pequeño artefacto binario para que
puedas inspeccionar algo más que texto.
import { setTimeout as delay } from 'node:timers/promises'
import type { LogEntry } from './archive.ts'
import { saveLogArchive } from './archive.ts'
async function* ciLogs(signal: AbortSignal): AsyncGenerator<LogEntry> {
yield { filename: 'build.log', content: 'Build completed successfully.\n' }
await delay(25, undefined, { signal })
yield { filename: 'test.log', content: 'All tests passed.\n' }
yield { filename: 'empty.log', content: Buffer.alloc(0) }
yield { filename: 'artifacts/status.bin', content: Buffer.from([0, 255, 128, 10]) }
}
async function main(): Promise<void> {
const controller = new AbortController()
const cancel = () => controller.abort(new Error('Interrupted'))
process.once('SIGINT', cancel)
try {
const destination = process.argv[2] ?? 'ci-logs.tar'
await saveLogArchive(ciLogs, destination, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]),
})
console.log(`Created ${destination}`)
} finally {
process.removeListener('SIGINT', cancel)
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Archive failed')
process.exitCode = 1
})
Desde el directorio padre usado para la configuración, pega lo siguiente:
(
cd node-tar-demo &&
node demo.ts &&
tar -tf ci-logs.tar &&
tar -xOf ci-logs.tar test.log
)
Salida esperada:
Created ci-logs.tar
build.log
test.log
empty.log
artifacts/status.bin
All tests passed.
Ejecuta de nuevo el mismo bloque para comprobar la política de colisiones: Node termina con el
estado 1 y un mensaje EEXIST, los comandos de inspección no se ejecutan y el
archivo tar existente conserva sus bytes. Para crear otro archivo tar, proporciona a
demo.ts un argumento de destino diferente, como
ci-logs-next.tar.
Consideraciones de rendimiento
Esperar a que termine cada entrada evita que el productor ponga todo el archivo tar en cola mientras una salida lenta sigue vaciando su búfer. Esto no reduce el tamaño de una cadena o un Buffer individual, y un productor que ya haya recopilado todos los logs seguirá ocupando esa memoria. Proporciona los logs completos de forma incremental, con un límite de tamaño adecuado para tus tareas de CI.
Tar agrupa archivos; este ejemplo no los comprime. Escribe toda la salida tar una vez en disco. El paso de publicación mediante enlace duro añade un nombre de archivo sin copiar esos bytes. Aquí no se incluye ninguna prueba de rendimiento que demuestre una mejora de velocidad frente a una herramienta de archivado de línea de comandos.
Consideraciones de seguridad
Los nombres de las entradas deben ser rutas de archivo relativas. El validador rechaza rutas
absolutas, separadores de unidad o de flujo alternativo, bytes nulos, segmentos vacíos y segmentos
. o .. antes de normalizar las barras
invertidas. Usa nombres distintos para tus logs: esta función auxiliar no rechaza entradas
duplicadas. Crea entradas de archivos normales con el modo 0600, sin
inferir permisos de ejecución a partir de la extensión del nombre de archivo.
Evita que los logs de CI contengan secretos antes de archivarlos. Tar no ofrece cifrado, y estas validaciones de nombres no hacen que sea seguro extraer archivos tar arbitrarios de terceros. Elige por separado los destinos de extracción y las políticas de enlaces simbólicos si más adelante creas una herramienta de extracción.
Manejo de distintos tipos de archivo
Pasa el texto UTF-8 como una cadena y los datos binarios como un Buffer.
tar-stream calcula el tamaño de la entrada a partir de esos bytes, incluso si el
Buffer está vacío; no se necesita decodificar texto para status.bin. Para un
stream de archivo, tar necesita conocer su tamaño en bytes antes del cuerpo de la entrada.
Este ejemplo acepta intencionalmente logs completos, en lugar de afirmar que archiva un log cuyo
tamaño final aún se desconoce.
Solución de problemas comunes
EEXIST: El destino final ya existe. El rechazo ocurre durante la publicación, después de que se hayan producido los logs. Elige otro nombre de archivo; el script nunca reemplaza uno existente.- Rechazo del productor, entrada no válida o fallo de escritura: Ambas tareas se resuelven,
ya sea con éxito o con error, antes de limpiar los archivos temporales. No se publica ningún
archivo tar final nuevo. Cuando usas
createLogArchivedirectamente con otro Writable, eres responsable de eliminar los bytes parciales que queden en ese destino. - Tiempo de espera agotado o Ctrl+C: La demostración solicita la cancelación y termina sin éxito después de la limpieza. Haz que los productores externos respeten la señal proporcionada. Una vez iniciada la operación de enlace final, la cancelación puede llegar demasiado tarde para impedir la publicación.
- Directorio inexistente o error de enlace duro: Crea primero el directorio padre del destino y comprueba que el sistema de archivos local permita enlaces duros. No sustituyas el enlace por un cambio de nombre que sobrescriba el destino para suprimir el error.
- Fallo de limpieza o terminación abrupta: Puede producirse un error de limpieza después de la
publicación; inspecciona el archivo tar final antes de volver a intentarlo. Un fallo abrupto o
SIGKILLpuede dejar un directorio.ci-logs-*. Elimina el directorio temporal de ese intento solo después de que su proceso se haya detenido. Este ejemplo no promete durabilidad ante un corte de energía.
Conecta tu productor de logs de CI
Mantén saveLogArchive como punto de salida a archivos y sustituye el generador de la
demostración por los resultados de tus pasos de CI. Entrega cada log completo, propaga la
cancelación a las esperas o solicitudes y elige un nombre de archivo tar único para la tarea.
La misma función auxiliar de transmisión puede escribir en otro Writable si proporcionas la
política de publicación y limpieza propia de ese destino.
