CI-Logarchive mit Node.js und tar-stream erstellen
Schreiben Sie mit tar-stream abgeschlossene CI-Logs in ein tar-Archiv, sobald
sie eintreffen. Das folgende Beispiel streamt das Archiv in eine temporäre Datei und stellt
ci-logs.tar erst bereit, nachdem jeder Eintrag vollständig geschrieben wurde.
Ein bestehendes Ziel bleibt unverändert, auch wenn ein Log-Produzent fehlschlägt.
Dies ist ein lokaler Node.js-Workflow für Logs, die als Strings oder Buffer bereitgestellt werden.
Jedes Log passt in den Arbeitsspeicher; das gesamte Archiv muss das nicht. Der Workflow erzeugt
eine unkomprimierte Datei im Format .tar auf der Festplatte, kein Archiv
im Arbeitsspeicher und keinen Live-Stream eines noch nicht abgeschlossenen Logs.
Abhängigkeiten
Verwenden Sie Node.js 24.15.0 oder eine neuere 24.x-Version, Corepack mit verfügbarem Yarn 4.12.0, Bash und GNU tar zur Inspektion. Die Anleitung wurde unter Linux mit Node.js 24.15.0 und GNU tar 1.35 getestet. Nutzen Sie ein lokales Dateisystem, das Hardlinks unterstützt, und ein Zielverzeichnis, das Sie kontrollieren. Netzwerkdateisysteme und Windows gehören nicht zum getesteten Umfang dieser Anleitung.
Fügen Sie Folgendes in Bash ein, ausgehend von einem übergeordneten Verzeichnis, in dem Sie das
Beispielprojekt anlegen möchten. Die Subshell lässt Ihr aktuelles Verzeichnis unverändert, und
&& stoppt die Einrichtung, wenn ein Schritt fehlschlägt. Falls
node-tar-demo bereits existiert, wählen Sie ein anderes übergeordnetes
Verzeichnis, statt ein bestehendes Projekt zu löschen.
(
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
)
Das Projekt aktiviert ES-Module explizit und installiert das reguläre
node_modules für den Befehl node. Seine eigene
Lockdatei verhindert, dass Yarn es als Teil eines übergeordneten Projekts behandelt. Node führt
diese TypeScript-Dateien mit nativem Type-Stripping aus;
der Code wird dabei ohne Typprüfung ausgeführt.
Einträge streamen und das fertige Archiv bereitstellen
Speichern Sie dieses vollständige Modul als node-tar-demo/archive.ts. Ein Produzent
empfängt ein AbortSignal und liefert jeweils ein abgeschlossenes Log.
Beginnen Sie, den Pack-Stream zu konsumieren, bevor Sie Einträge hinzufügen, und warten Sie auf
den Callback jedes Eintrags, bevor Sie das nächste Log anfordern. Diese Packvorgänge sind in der
Dokumentation zu tar-stream beschrieben.
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 })
}
}
Die Stream-Hilfsfunktion koordiniert die Produktion und das Schreiben.
pipeline() von Node handhabt Backpressure
und zerstört bei einem Abbruch die verbundenen Streams. Falls eine Seite fehlschlägt, bricht die
Hilfsfunktion auch den Produzenten ab und wartet, bis beide Aufgaben beendet sind. Ihr Produzent
muss das Signal an jeden Vorgang weitergeben, der warten kann, so wie der Timer in der Demo unten.
Ein beliebiges Promise, das das Signal ignoriert, lässt sich durch den Abbruch nicht stoppen.
Die Hilfsfunktion zum Speichern verwendet einen Hardlink, um der
fertigen Datei ihren endgültigen Namen zu geben.
link(2) unter Linux lehnt ein bereits
vorhandenes Ziel ab, auch einen Symlink. Es gibt keine separate Existenzprüfung mit anschließendem
überschreibendem Umbenennen. Das temporäre Verzeichnis liegt neben dem Ziel, damit sich beide
Namen auf demselben Dateisystem befinden. Wird der temporäre Name nach der Bereitstellung entfernt,
bleibt das endgültige Archiv intakt.
Beispiel ausführen und prüfen
Speichern Sie dies als node-tar-demo/demo.ts. Der Timer simuliert das Warten auf einen
weiteren abgeschlossenen CI-Schritt; ersetzen Sie bei der Integration
ciLogs durch Ihren eigenen Produzenten. Die Demo enthält einen leeren
Eintrag und ein kleines binäres Artefakt, damit Sie mehr als nur Text prüfen können.
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
})
Fügen Sie Folgendes im übergeordneten Verzeichnis ein, das Sie bei der Einrichtung verwendet haben:
(
cd node-tar-demo &&
node demo.ts &&
tar -tf ci-logs.tar &&
tar -xOf ci-logs.tar test.log
)
Erwartete Ausgabe:
Created ci-logs.tar
build.log
test.log
empty.log
artifacts/status.bin
All tests passed.
Führen Sie denselben Block erneut aus, um das Verhalten bei Namenskollisionen zu prüfen: Node endet
mit Status 1 und einer Meldung mit EEXIST, die Inspektionsbefehle werden
nicht ausgeführt, und die Bytes des bestehenden Archivs bleiben unverändert. Um ein weiteres
Archiv zu erstellen, übergeben Sie demo.ts ein anderes Zielargument,
zum Beispiel ci-logs-next.tar.
Hinweise zur Leistung
Das Warten auf jeden Eintrag verhindert, dass der Produzent das gesamte Archiv in die Warteschlange stellt, während eine langsame Ausgabe noch Daten abarbeitet. Es verkleinert weder einen einzelnen String noch einen Buffer. Ein Produzent, der bereits alle Logs gesammelt hat, belegt diesen Arbeitsspeicher weiterhin. Stellen Sie abgeschlossene Logs nach und nach bereit, mit einer für Ihre CI-Jobs passenden Größenbegrenzung.
Tar fasst Dateien zusammen; dieses Beispiel komprimiert sie nicht. Es schreibt die vollständige tar-Ausgabe einmal auf die Festplatte. Die Bereitstellung per Hardlink fügt einen Dateinamen hinzu, ohne diese Bytes zu kopieren. Hier gibt es keinen Benchmark, der einen Geschwindigkeitsvorteil gegenüber einem Befehlszeilen-Archivierungsprogramm belegt.
Sicherheitshinweise
Eintragsnamen müssen relative Dateipfade sein. Der Validator lehnt absolute Pfade, Trennzeichen
für Laufwerke oder alternative Datenströme, Nullbytes, leere Segmente sowie die Segmente
. oder .. ab, bevor er umgekehrte
Schrägstriche normalisiert. Verwenden Sie unterschiedliche Namen für Ihre Logs: Diese Hilfsfunktion
lehnt doppelte Einträge nicht ab. Sie erstellt reguläre Dateieinträge mit dem Modus
0600, ohne Ausführungsrechte aus einer Dateinamenerweiterung abzuleiten.
Halten Sie sensible Zugangsdaten aus CI-Logs heraus, bevor Sie diese archivieren. Tar bietet keine Verschlüsselung, und diese Namensprüfungen machen das Entpacken beliebiger Archive von Dritten nicht sicher. Legen Sie Entpackziele und Regeln für Symlinks separat fest, falls Sie später eine Funktion zum Entpacken entwickeln.
Verschiedene Dateitypen verarbeiten
Übergeben Sie UTF-8-Text als String und Binärdaten als Buffer. tar-stream
ermittelt die Eintragsgröße anhand dieser Bytes, auch bei einem leeren Buffer; für
status.bin ist keine Textdecodierung nötig. Bei einem Dateistream benötigt
tar die Größe in Bytes vor dem Inhalt des Eintrags. Dieses Beispiel akzeptiert bewusst
abgeschlossene Logs, statt vorzugeben, ein Log mit noch unbekannter endgültiger Größe zu archivieren.
Häufige Probleme beheben
EEXIST: Das endgültige Ziel existiert bereits. Die Ablehnung erfolgt bei der Bereitstellung, nachdem die Logs erzeugt wurden. Wählen Sie einen anderen Dateinamen; das Skript ersetzt niemals eine bestehende Datei.- Ablehnung durch den Produzenten, ungültiger Eintrag oder Schreibfehler: Beide Aufgaben enden,
bevor die temporären Dateien bereinigt werden. Es wird kein neues endgültiges Archiv
bereitgestellt. Wenn Sie
createLogArchivedirekt mit einem anderen Writable verwenden, sind Sie dafür verantwortlich, bereits teilweise geschriebene Bytes am Ziel zu entfernen. - Zeitüberschreitung oder Ctrl+C: Die Demo fordert einen Abbruch an und endet nach der Bereinigung mit einem Fehlerstatus. Stellen Sie sicher, dass externe Produzenten das übergebene Signal beachten. Sobald der abschließende Link-Vorgang begonnen hat, kann der Abbruch zu spät eintreffen, um die Bereitstellung zu verhindern.
- Fehlendes Verzeichnis oder Hardlink-Fehler: Erstellen Sie zuerst das übergeordnete Zielverzeichnis und prüfen Sie, ob das lokale Dateisystem Hardlinks erlaubt. Ersetzen Sie den Link nicht durch ein überschreibendes Umbenennen, um den Fehler zu unterdrücken.
- Fehler bei der Bereinigung oder abruptes Prozessende: Ein Bereinigungsfehler kann nach der
Bereitstellung auftreten; prüfen Sie das endgültige Archiv vor einem erneuten Versuch. Ein
Absturz oder
SIGKILLkann ein Verzeichnis namens.ci-logs-*zurücklassen. Entfernen Sie das temporäre Verzeichnis dieses Versuchs erst, nachdem der zugehörige Prozess beendet wurde. Dieses Beispiel garantiert keine dauerhafte Datensicherung bei einem Stromausfall.
Ihren CI-Log-Produzenten anbinden
Behalten Sie saveLogArchive als Schnittstelle für die Dateiausgabe bei und
ersetzen Sie den Demo-Generator durch die Ergebnisse Ihrer CI-Schritte. Liefern Sie jedes
abgeschlossene Log, reichen Sie Abbruchsignale an Wartevorgänge oder Anfragen weiter und wählen
Sie einen für den Job eindeutigen Archivdateinamen. Dieselbe Stream-Hilfsfunktion kann in ein
anderes Writable schreiben, wenn Sie für dieses Ziel eigene Regeln zur Bereitstellung und
Bereinigung vorgeben.
