Créer des archives de journaux de CI avec Node.js et tar-stream
Utilisez tar-stream pour écrire les journaux de CI terminés dans une archive tar à mesure qu’ils
arrivent. L’exemple ci-dessous écrit l’archive en flux dans un fichier temporaire, puis publie
ci-logs.tar uniquement une fois que chaque entrée est terminée. Une destination existante reste
intacte, y compris lorsqu’un producteur de journaux échoue.
Il s’agit d’un flux de travail Node.js local pour des journaux fournis sous forme de chaînes ou de
Buffers. Chaque journal tient en mémoire ; l’archive combinée n’a pas besoin d’y tenir. Il produit
un fichier .tar non compressé sur disque, et non une archive en mémoire ni un flux en direct
d’un journal inachevé.
Dépendances
Utilisez Node.js 24.15.0 ou une version 24.x ultérieure, Corepack avec Yarn 4.12.0 disponible, Bash, et GNU tar pour l’inspection. Ce tutoriel a été testé sous Linux avec Node.js 24.15.0 et GNU tar 1.35. Utilisez un système de fichiers local qui prend en charge les liens physiques, ainsi qu’un répertoire de destination que vous contrôlez. Les systèmes de fichiers réseau et Windows sortent du périmètre testé de ce tutoriel.
Collez ceci dans Bash depuis un répertoire parent où vous souhaitez créer le projet d’exemple. Le
sous-shell laisse votre répertoire courant inchangé, et && interrompt la configuration si
une étape échoue. Si node-tar-demo existe déjà, choisissez un autre répertoire parent plutôt que de
supprimer un projet existant.
(
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
)
Le projet active explicitement les modules ES et installe les dépendances dans un répertoire
node_modules classique pour la commande node. Son propre fichier de verrouillage empêche Yarn
de le traiter comme une partie d’un projet englobant. Node exécute ces fichiers TypeScript grâce à
la suppression native des types ; le code est ainsi exécuté sans vérification de
types.
Écrire les entrées en flux et publier l’archive terminée
Enregistrez ce module complet sous node-tar-demo/archive.ts. Un producteur reçoit un AbortSignal et produit un
journal terminé à la fois. Commencez à consommer le flux d’empaquetage avant d’ajouter des entrées,
et attendez le callback de chaque entrée avant de demander un autre journal. Ce sont les opérations
d’empaquetage décrites dans la documentation 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 fonction utilitaire de flux coordonne la production et l’écriture.
Le pipeline() de Node gère la contre-pression et détruit les flux connectés en cas
d’annulation. Si l’un des deux côtés échoue, la fonction utilitaire annule également le producteur
et attend que les deux tâches se terminent. Votre producteur doit transmettre le signal à toute
opération susceptible d’attendre, comme le fait le minuteur de la démo ci-dessous. L’annulation ne
peut pas arrêter une promesse arbitraire qui ignore le signal.
La fonction utilitaire d’enregistrement utilise un lien physique pour donner son nom
final au fichier terminé. Le link(2) de Linux refuse une destination existante, y
compris un lien symbolique. Il n’y a pas de vérification d’existence distincte suivie d’un renommage
avec écrasement. Le répertoire temporaire se trouve à côté de la destination afin que les deux noms
soient sur le même système de fichiers. Supprimer le nom temporaire après la publication laisse
l’archive finale intacte.
Exécuter et inspecter l’exemple
Enregistrez ceci sous node-tar-demo/demo.ts. Le minuteur simule l’attente d’une autre étape de CI terminée ;
remplacez ciLogs par votre propre producteur lors de l’intégration. La démo inclut une entrée
vide et un petit artefact binaire afin que vous puissiez inspecter autre chose que du texte.
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
})
Depuis le répertoire parent utilisé pour la configuration, collez :
(
cd node-tar-demo &&
node demo.ts &&
tar -tf ci-logs.tar &&
tar -xOf ci-logs.tar test.log
)
Sortie attendue :
Created ci-logs.tar
build.log
test.log
empty.log
artifacts/status.bin
All tests passed.
Exécutez à nouveau le même bloc pour vérifier la politique de collision : Node se termine avec le
code de sortie 1 et un message EEXIST, les commandes d’inspection ne s’exécutent pas, et
l’archive existante conserve ses octets. Pour créer une autre archive, donnez à demo.ts un autre
argument de destination, tel que ci-logs-next.tar.
Considérations de performance
Attendre chaque entrée empêche le producteur de mettre toute l’archive en file d’attente pendant qu’une sortie lente est encore en train de se vider. Cela ne réduit pas la taille d’une chaîne ou d’un Buffer individuel, et un producteur qui a déjà collecté tous les journaux conserve toujours cette mémoire. Fournissez les journaux terminés de manière incrémentale, avec une limite de taille adaptée à vos tâches de CI.
Tar regroupe des fichiers ; cet exemple ne les compresse pas. Il écrit une seule fois la sortie tar complète sur disque. L’étape de publication par lien physique ajoute un nom de fichier sans copier ces octets. Aucun benchmark présenté ici n’établit un gain de vitesse par rapport à un archiveur en ligne de commande.
Considérations de sécurité
Les noms d’entrée doivent être des chemins de fichiers relatifs. Le validateur rejette les chemins
absolus, les séparateurs de lecteur ou de flux alternatif, les octets nuls, les segments vides et
les segments . ou .. avant de normaliser les barres obliques inverses. Utilisez
des noms distincts pour vos journaux : cette fonction utilitaire ne rejette pas les entrées en
double. Elle crée des entrées de fichier ordinaires avec le mode 0600, sans deviner les
permissions d’exécution à partir de l’extension du nom de fichier.
Retirez les secrets des journaux de CI avant de les archiver. Tar ne fournit aucun chiffrement, et ces vérifications de noms ne rendent pas sûre l’extraction d’archives arbitraires provenant de tiers. Choisissez séparément les destinations d’extraction et les politiques de liens symboliques si vous développez plus tard un extracteur.
Gérer différents types de fichiers
Transmettez le texte UTF-8 sous forme de chaîne et les données binaires sous forme de Buffer.
tar-stream déduit la taille de l’entrée à partir de ces octets, y compris pour un Buffer vide ;
aucun décodage de texte n’est nécessaire pour status.bin. Pour un flux de fichier, tar a besoin de
connaître sa taille en octets avant le corps de l’entrée. Cet exemple accepte volontairement des
journaux terminés au lieu de prétendre archiver un journal dont la taille finale est encore
inconnue.
Résoudre les problèmes courants
EEXIST: la destination finale existe déjà. Le refus intervient lors de la publication, après la production des journaux. Choisissez un autre nom de fichier ; le script ne remplace jamais un fichier existant.- Rejet du producteur, entrée invalide ou échec d’écriture : les deux tâches se terminent avant
le nettoyage des fichiers temporaires. Aucune nouvelle archive finale n’est publiée. Si vous
utilisez
createLogArchivedirectement avec un autre Writable, il vous incombe d’éliminer les octets partiels présents dans cette destination. - Délai dépassé ou Ctrl+C : la démo demande l’annulation et se termine en échec après le nettoyage. Faites en sorte que les producteurs externes respectent le signal fourni. Une fois l’opération de lien finale commencée, l’annulation peut arriver trop tard pour empêcher la publication.
- Répertoire manquant ou erreur de lien physique : créez d’abord le répertoire parent de la destination et vérifiez que le système de fichiers local autorise les liens physiques. Ne remplacez pas le lien par un renommage avec écrasement pour masquer l’erreur.
- Échec du nettoyage ou arrêt brutal : une erreur de nettoyage peut survenir après la
publication ; inspectez l’archive finale avant de réessayer. Un plantage ou
SIGKILLpeut laisser un répertoire.ci-logs-*. Supprimez le répertoire temporaire de cette tentative uniquement après l’arrêt de son processus. Cet exemple ne garantit pas la durabilité en cas de coupure de courant.
Connecter votre producteur de journaux de CI
Conservez saveLogArchive comme frontière de sortie vers le fichier et remplacez le générateur de démo
par les résultats de vos étapes de CI. Produisez chaque journal terminé, transmettez l’annulation aux
attentes ou aux requêtes, et choisissez un nom de fichier d’archive propre à la tâche. La même
fonction utilitaire de flux peut écrire vers un autre Writable si vous fournissez la politique de
publication et de nettoyage propre à cette destination.
