Crie arquivos tar de logs de CI com Node.js e tar-stream
Use tar-stream para gravar logs de CI concluídos em um arquivo tar à medida que eles chegam. O exemplo
abaixo transmite o arquivo tar para um arquivo temporário e só publica ci-logs.tar depois que todas as
entradas terminam. Um destino existente permanece intacto, inclusive quando um produtor de logs falha.
Este é um fluxo de trabalho local em Node.js para logs fornecidos como strings ou Buffers. Cada log
cabe na memória; o arquivo tar combinado não precisa caber. Ele produz um arquivo .tar sem
compressão no disco, não um arquivo tar em memória nem um stream ao vivo de um log inacabado.
Dependências
Use o Node.js 24.15.0 ou uma versão 24.x posterior, o Corepack com o Yarn 4.12.0 disponível, o Bash e o GNU tar para inspeção. O passo a passo foi testado no Linux com Node.js 24.15.0 e GNU tar 1.35. Use um sistema de arquivos local que suporte hard links e um diretório de destino que você controle. Sistemas de arquivos de rede e o Windows estão fora do escopo testado deste passo a passo.
Cole isto no Bash a partir de um diretório pai onde você quer criar o projeto de exemplo. O subshell
mantém seu diretório atual inalterado, e && interrompe a configuração se uma etapa falhar. Se
node-tar-demo já existir, escolha outro diretório pai em vez de excluir um projeto 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
)
O projeto habilita explicitamente os módulos ES e instala o node_modules comum para o comando
node. O lockfile próprio do projeto impede que o Yarn o trate como parte de um projeto que o
envolva. O Node executa esses arquivos TypeScript usando
remoção nativa de tipos; isso executa o código sem verificar os tipos.
Transmitir entradas e publicar o arquivo tar finalizado
Salve este módulo completo como node-tar-demo/archive.ts. Um produtor recebe um AbortSignal e
entrega um log concluído por vez. Comece a consumir o stream de empacotamento antes de adicionar
entradas e aguarde o callback de cada entrada antes de solicitar outro log. Essas são as operações de
empacotamento descritas na documentação do 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 })
}
}
O helper de stream coordena a produção e a gravação. O
pipeline() do Node lida com a contrapressão (backpressure) e destrói os streams
conectados em caso de cancelamento. Se qualquer um dos lados falhar, o helper também cancela o
produtor e aguarda as duas tarefas terminarem. Seu produtor precisa repassar o sinal para qualquer
operação que possa ficar esperando, como faz o timer da demonstração abaixo. O cancelamento não
consegue interromper uma promise arbitrária que ignore o sinal.
O helper de salvamento usa um hard link para dar ao arquivo concluído seu nome
final. O link(2) do Linux recusa um destino existente, inclusive um symlink. Não há
uma verificação de existência separada seguida de um rename que sobrescreva o destino. O diretório
temporário fica ao lado do destino para que os dois nomes estejam no mesmo sistema de arquivos.
Remover o nome temporário após a publicação mantém o arquivo tar final intacto.
Executar e inspecionar o exemplo
Salve isto como node-tar-demo/demo.ts. O timer simula a espera por outra etapa de CI concluída; substitua
ciLogs pelo seu próprio produtor ao fazer a integração. A demonstração inclui uma entrada vazia e
um pequeno artefato binário para que você possa inspecionar mais do 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
})
A partir do diretório pai usado na configuração, cole:
(
cd node-tar-demo &&
node demo.ts &&
tar -tf ci-logs.tar &&
tar -xOf ci-logs.tar test.log
)
Saída esperada:
Created ci-logs.tar
build.log
test.log
empty.log
artifacts/status.bin
All tests passed.
Execute o mesmo bloco novamente para verificar a política de colisão: o Node encerra com status 1 e
uma mensagem EEXIST, os comandos de inspeção não são executados e o arquivo tar existente mantém
seus bytes. Para criar outro arquivo tar, passe para demo.ts um argumento de destino diferente,
como ci-logs-next.tar.
Considerações de desempenho
Aguardar cada entrada impede que o produtor enfileire o arquivo tar inteiro enquanto uma saída lenta ainda está sendo drenada. Isso não torna uma string ou um Buffer individual menor, e um produtor que já coletou todos os logs continua retendo essa memória. Forneça logs concluídos de forma incremental, com um limite de tamanho adequado aos seus trabalhos de CI.
O tar agrupa arquivos; este exemplo não os comprime. Ele grava a saída tar completa uma única vez no disco. A etapa de publicação por hard link adiciona um nome de arquivo sem copiar esses bytes. Não há aqui nenhum benchmark que comprove uma melhoria de velocidade em relação a um arquivador de linha de comando.
Considerações de segurança
Os nomes das entradas precisam ser caminhos de arquivo relativos. O validador rejeita caminhos
absolutos, separadores de unidade ou de fluxo alternativo, bytes nulos, segmentos vazios e segmentos
. ou .. antes de normalizar as barras invertidas. Use nomes distintos para seus
logs: este helper não rejeita entradas duplicadas. Ele cria entradas de arquivo comuns com modo
0600, sem deduzir permissões de execução a partir da extensão do nome do arquivo.
Mantenha segredos fora dos logs de CI antes de arquivá-los. O tar não oferece criptografia, e essas verificações de nome não tornam segura a extração de arquivos tar arbitrários de terceiros. Escolha destinos de extração e políticas de symlink separadamente se, mais tarde, você criar um extrator.
Lidar com diferentes tipos de arquivo
Passe texto UTF-8 como string e dados binários como Buffer. tar-stream deriva o tamanho da entrada a
partir desses bytes, inclusive de um Buffer vazio; nenhuma decodificação de texto é necessária para
status.bin. Para um stream de arquivo, o tar precisa saber o tamanho em bytes antes do corpo da
entrada. Este exemplo aceita intencionalmente logs concluídos em vez de alegar arquivar um log cujo
tamanho final ainda é desconhecido.
Solucionar problemas comuns
EEXIST: O destino final já existe. A recusa acontece na publicação, depois que os logs foram produzidos. Escolha outro nome de arquivo; o script nunca substitui um arquivo existente.- Rejeição do produtor, entrada inválida ou falha de gravação: As duas tarefas terminam antes da
limpeza do arquivo temporário. Nenhum novo arquivo tar final é publicado. Ao usar
createLogArchivediretamente com outro Writable, você é responsável por descartar quaisquer bytes parciais nesse destino. - Tempo limite ou Ctrl+C: A demonstração solicita o cancelamento e encerra sem sucesso após a limpeza. Faça com que produtores externos observem o sinal fornecido. Depois que a operação final de link começa, o cancelamento pode chegar tarde demais para impedir a publicação.
- Diretório ausente ou erro de hard link: Crie primeiro o diretório pai do destino e verifique se o sistema de arquivos local permite hard links. Não substitua o link por um rename que sobrescreva o destino para suprimir o erro.
- Falha na limpeza ou encerramento abrupto: Um erro de limpeza pode ocorrer após a publicação;
inspecione o arquivo tar final antes de tentar novamente. Um travamento ou um
SIGKILLpode deixar um diretório.ci-logs-*. Remova o diretório temporário dessa tentativa somente depois que o processo dela tiver parado. Este exemplo não promete durabilidade em caso de queda de energia.
Conectar seu produtor de logs de CI
Mantenha saveLogArchive como a fronteira de saída de arquivo e substitua o gerador da demonstração pelos
resultados das suas etapas de CI. Entregue cada log concluído, repasse o cancelamento para esperas ou
requisições e escolha um nome de arquivo tar exclusivo para o trabalho de CI. O mesmo helper de
streaming pode gravar em outro Writable quando você fornecer a política de publicação e limpeza
própria desse destino.
