-
Notifications
You must be signed in to change notification settings - Fork 7
feat(deploy): stage streamed payloads to a temp file for replication #536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
167f5b0
e6c5f78
c293478
f91a5fa
e95512d
5a75dc9
fc8530e
1fdcf09
39a5971
e190a48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { createWriteStream } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { mkdtemp, rm } from 'node:fs/promises'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { join } from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { tmpdir } from 'node:os'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { pipeline } from 'node:stream/promises'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Readable } from 'node:stream'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Buffer a streamed deploy_component payload to a temp tar.gz file on disk. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Used by replicated deploys: the origin needs to keep a copy of the streamed payload so | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * it can re-stream it to each peer over the HTTPS relay (see harper-pro's deployRelay). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Without this, the payload Readable would be consumed once by local extraction and gone | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * by the time replication runs. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Trade-off: we write the full payload to disk before extraction reads from it (instead of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * tee-ing in-flight) so the local-deploy path stays unchanged — extractApplication still | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * gets a regular createReadStream over a complete file. Two passes over the data, but no | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * concurrent-tee complexity, and disk speed isn't the bottleneck on deploy. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Returns the staged file's path and a cleanup function. The cleanup deletes the temp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * directory; safe to call multiple times (rm with force). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function stagePayloadToTempFile( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| source: Readable, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| projectName: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<{ path: string; cleanup: () => Promise<void> }> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dir = await mkdtemp(join(tmpdir(), `harper-deploy-${sanitize(projectName)}-`)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const path = join(dir, 'payload.tar.gz'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await pipeline(source, createWriteStream(path)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temp dir leaks on pipeline failure (still open from prior run)
Suggested change
Also add a test assertion: after
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temp dir leaks on pipeline failure (carry from prior run)
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temp dir leaks on pipeline failure (carry from prior run — still open)
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temp dir leaks on pipeline failure (carry from prior run — still open)
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still leaks
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cleanup = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await rm(dir, { recursive: true, force: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { path, cleanup }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function sanitize(name: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // keep alphanumerics, dashes, underscores; replace everything else so a malicious or | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // quirky project name (slashes, dots, control chars) can't escape the tmpdir. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| 'use strict'; | ||
|
|
||
| const assert = require('node:assert'); | ||
| const { Readable } = require('node:stream'); | ||
| const { readFile, stat } = require('node:fs/promises'); | ||
| const path = require('node:path'); | ||
| const testUtils = require('../testUtils.js'); | ||
| testUtils.preTestPrep(); | ||
|
|
||
| const { stagePayloadToTempFile } = require('#src/components/payloadStaging'); | ||
|
|
||
| describe('stagePayloadToTempFile', () => { | ||
| it('writes a streamed payload to a temp file with the expected contents', async () => { | ||
| const payload = Buffer.from('abcdefghij'.repeat(10000), 'utf8'); // 100 KB | ||
| const { path: tmpPath, cleanup } = await stagePayloadToTempFile(Readable.from(payload), 'demo'); | ||
| try { | ||
| const written = await readFile(tmpPath); | ||
| assert.strictEqual(written.length, payload.length); | ||
| assert.deepStrictEqual(written, payload); | ||
| assert.match(tmpPath, /harper-deploy-demo-/, 'temp dir is named after the project'); | ||
| assert.strictEqual(path.basename(tmpPath), 'payload.tar.gz'); | ||
| } finally { | ||
| await cleanup(); | ||
| } | ||
| }); | ||
|
|
||
| it('cleanup() removes the staged file and its parent temp dir', async () => { | ||
| const { path: tmpPath, cleanup } = await stagePayloadToTempFile(Readable.from('hello'), 'cleanup-test'); | ||
| await cleanup(); | ||
| await assert.rejects(stat(tmpPath), /ENOENT/, 'staged file must be gone'); | ||
| await assert.rejects(stat(path.dirname(tmpPath)), /ENOENT/, 'staged dir must be gone'); | ||
| }); | ||
|
|
||
| it('cleanup() is safe to call twice (force: true)', async () => { | ||
| const { cleanup } = await stagePayloadToTempFile(Readable.from('hello'), 'double-cleanup'); | ||
| await cleanup(); | ||
| await cleanup(); // must not throw | ||
| }); | ||
|
|
||
| it('sanitizes path-traversal characters in the project name', async () => { | ||
| const { path: tmpPath, cleanup } = await stagePayloadToTempFile(Readable.from('x'), '../../evil/name'); | ||
| try { | ||
| // Path separators must be replaced so the temp dir lives in a single mkdtemp slot | ||
| // directly under tmpdir, not navigated upstream. `..` segments alone don't traverse | ||
| // because there's no `/` between them after sanitization. | ||
| const os = require('node:os'); | ||
| assert.strictEqual( | ||
| path.dirname(path.dirname(tmpPath)), | ||
| path.resolve(os.tmpdir()), | ||
| 'staged dir must live directly under tmpdir' | ||
| ); | ||
| assert.doesNotMatch(path.basename(path.dirname(tmpPath)), /\//); | ||
| assert.match(path.basename(path.dirname(tmpPath)), /harper-deploy-.+_evil_name-/); | ||
| } finally { | ||
| await cleanup(); | ||
| } | ||
| }); | ||
|
|
||
| it('propagates source-stream errors through pipeline', async () => { | ||
| const source = new Readable({ | ||
| read() { | ||
| this.destroy(new Error('source boom')); | ||
| }, | ||
| }); | ||
| await assert.rejects(stagePayloadToTempFile(source, 'erroring'), /source boom/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This catches
prepareApplicationfailures, but there's a gap between thiscatchand thereplicateOperationfinally: the load-phasethrow lastErrorpath (theif (lastError) throw lastErrorblock that runs when the pseudo-resource load fails, after line ~452) is not covered by either cleanup handler. If a multi-node streaming deploy reaches the load phase and the component fails to load,stagedPayloadleaks.Same one-liner before
throw lastErrorin the load-error branch (or restructure with a single outertry/finallywrapping both phases).