diff --git a/CHANGELOG.md b/CHANGELOG.md index d47d9a1..6ed3577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Available as `edge` - Improved: Updated Node.js to version 24 and removed unsupported ARMv7 images [PR-2448](https://github.com/caprover/caprover/pull/2448) +## [1.15.3] - 2026-08-20 + +- Fixed: Restored build output when using the BuildKit builder [Issue-2461](https://github.com/caprover/caprover/issues/2461) +- Fixed: Tar file deployments failing from the dashboard [Issue-2463](https://github.com/caprover/caprover/issues/2463) + ## [1.15.2] - 2026-08-14 - Fixed: Legacy custom Captain NGINX templates failing after upgrading to v1.15.0 [Issue-2455](https://github.com/caprover/caprover/issues/2455) diff --git a/dev-scripts/build_and_push_release.sh b/dev-scripts/build_and_push_release.sh index 3ce363e..cb046a8 100755 --- a/dev-scripts/build_and_push_release.sh +++ b/dev-scripts/build_and_push_release.sh @@ -44,7 +44,7 @@ echo $IMAGE_NAME:$CAPROVER_VERSION echo "**************************************" echo "**************************************" -FRONTEND_COMMIT_HASH=5db8b202774fb50c9ecdddc42020bb454d64c42d +FRONTEND_COMMIT_HASH=35dccc3e099c06f295f78c50d131fc5def91f995 ## Building frontend app ORIG_DIR=$(pwd) diff --git a/src/docker/DockerApi.ts b/src/docker/DockerApi.ts index f47def7..cf46436 100644 --- a/src/docker/DockerApi.ts +++ b/src/docker/DockerApi.ts @@ -11,6 +11,11 @@ import { import { DockerAuthObj, DockerRegistryConfig } from '../models/DockerAuthObj' import { DockerSecret } from '../models/DockerSecret' import DockerService from '../models/DockerService' +import { + DockerBuildOutputDecoder, + DockerJsonMessage, + DockerJsonStreamParser, +} from './DockerBuildOutput' import { IHashMapGeneric } from '../models/ICacheGeneric' import { IDockerApiPort, @@ -30,35 +35,6 @@ import dockerodeUtils = require('dockerode/lib/util') const Base64 = Base64Provider.Base64 const CAPROVER_MANAGED_SERVICE_LABEL = 'com.caprover.managed' -function safeParseChunk(chunk: string): { - stream?: string - error?: any - errorDetail?: any -}[] { - chunk = `${chunk}`.trim() - try { - // See https://github.com/caprover/caprover/issues/570 - // This appears to be bug either in Docker or dockerone: - // Sometimes chunk appears as two JSON objects, like - // ``` - // {"stream":"something......"} - // {"stream":"another line of things"} - // ``` - const chunks = chunk.split('\n') - const returnVal = [] as any[] - chunks.forEach((chk) => { - returnVal.push(JSON.parse(chk)) - }) - return returnVal - } catch (ignore) { - return [ - { - stream: `Cannot parse ${chunk}`, - }, - ] - } -} - export abstract class IDockerUpdateOrders { public static readonly AUTO = 'auto' public static readonly STOP_FIRST = 'stopFirst' @@ -374,13 +350,11 @@ class DockerApi { .then(function (stream) { return new Promise(function (resolve, reject) { let errorMessage = '' + const streamParser = new DockerJsonStreamParser() + const outputDecoder = new DockerBuildOutputDecoder() - stream.setEncoding('utf8') - - // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. - stream.on('data', function (chunkRaw) { - Logger.dev(`stream data ${chunkRaw}`) - safeParseChunk(chunkRaw).forEach((chunk) => { + function processMessage(message: DockerJsonMessage) { + outputDecoder.decode(message).forEach((chunk) => { const chuckStream = chunk.stream if (chuckStream) { // Logger.dev('stream data ' + chuckStream); @@ -401,6 +375,14 @@ class DockerApi { errorMessage += chunk.error } }) + } + + stream.setEncoding('utf8') + + // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. + stream.on('data', function (chunkRaw) { + Logger.dev(`stream data ${chunkRaw}`) + streamParser.push(chunkRaw).forEach(processMessage) }) // stream.pipe(process.stdout, {end: true}); @@ -408,6 +390,7 @@ class DockerApi { // https://nodejs.org/api/stream.html#stream_event_end stream.on('end', function () { + streamParser.flush().forEach(processMessage) if (errorMessage) { reject(errorMessage) return @@ -452,32 +435,35 @@ class DockerApi { return new Promise(function (resolve, reject) { let errorMessage = '' const logsBeforeError: string[] = [] + const streamParser = new DockerJsonStreamParser() for (let i = 0; i < 20; i++) { logsBeforeError.push('') } + function processMessage(chunk: DockerJsonMessage) { + const chuckStream = chunk.stream + if (chuckStream) { + // Logger.dev('stream data ' + chuckStream); + logsBeforeError.shift() + logsBeforeError.push(chuckStream) + } + + if (chunk.error) { + Logger.e(chunk.error) + Logger.e(JSON.stringify(chunk.errorDetail)) + errorMessage += '\n [truncated] \n' + errorMessage += logsBeforeError.join('') + errorMessage += '\n' + errorMessage += chunk.error + } + } + stream.setEncoding('utf8') // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. stream.on('data', function (chunkRaw) { Logger.dev(`stream data ${chunkRaw}`) - safeParseChunk(chunkRaw).forEach((chunk) => { - const chuckStream = chunk.stream - if (chuckStream) { - // Logger.dev('stream data ' + chuckStream); - logsBeforeError.shift() - logsBeforeError.push(chuckStream) - } - - if (chunk.error) { - Logger.e(chunk.error) - Logger.e(JSON.stringify(chunk.errorDetail)) - errorMessage += '\n [truncated] \n' - errorMessage += logsBeforeError.join('') - errorMessage += '\n' - errorMessage += chunk.error - } - }) + streamParser.push(chunkRaw).forEach(processMessage) }) // stream.pipe(process.stdout, {end: true}); @@ -485,6 +471,7 @@ class DockerApi { // https://nodejs.org/api/stream.html#stream_event_end stream.on('end', function () { + streamParser.flush().forEach(processMessage) if (errorMessage) { reject(errorMessage) return @@ -742,32 +729,35 @@ class DockerApi { .then(function (stream) { return new Promise(function (resolve, reject) { let errorMessage = '' + const streamParser = new DockerJsonStreamParser() + + function processMessage(chunk: DockerJsonMessage) { + const chuckStream = chunk.stream + if (chuckStream) { + // Logger.dev('stream data ' + chuckStream); + buildLogs.log(chuckStream) + } + + if (chunk.error) { + Logger.e(chunk.error) + const errorDetails = JSON.stringify( + chunk.errorDetail + ) + Logger.e(errorDetails) + buildLogs.log(errorDetails) + buildLogs.log(chunk.error) + errorMessage += '\n' + errorMessage += errorDetails + errorMessage += chunk.error + } + } stream.setEncoding('utf8') // THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE. stream.on('data', function (chunkRaw) { Logger.dev(`stream data ${chunkRaw}`) - safeParseChunk(chunkRaw).forEach((chunk) => { - const chuckStream = chunk.stream - if (chuckStream) { - // Logger.dev('stream data ' + chuckStream); - buildLogs.log(chuckStream) - } - - if (chunk.error) { - Logger.e(chunk.error) - const errorDetails = JSON.stringify( - chunk.errorDetail - ) - Logger.e(errorDetails) - buildLogs.log(errorDetails) - buildLogs.log(chunk.error) - errorMessage += '\n' - errorMessage += errorDetails - errorMessage += chunk.error - } - }) + streamParser.push(chunkRaw).forEach(processMessage) }) // stream.pipe(process.stdout, {end: true}); @@ -775,6 +765,7 @@ class DockerApi { // https://nodejs.org/api/stream.html#stream_event_end stream.on('end', function () { + streamParser.flush().forEach(processMessage) if (errorMessage) { buildLogs.log('Push failed...') reject(errorMessage) diff --git a/src/docker/DockerBuildOutput.ts b/src/docker/DockerBuildOutput.ts new file mode 100644 index 0000000..d87eb21 --- /dev/null +++ b/src/docker/DockerBuildOutput.ts @@ -0,0 +1,296 @@ +export interface DockerJsonMessage { + stream?: string + error?: any + errorDetail?: any + id?: string + aux?: unknown +} + +interface ProtobufValue { + value: number + offset: number +} + +interface ProtobufBytes { + value: Buffer + offset: number +} + +interface BuildKitVertex { + digest: string + name: string + error: string +} + +interface BuildKitStatus { + vertices: BuildKitVertex[] + logs: Buffer[] + warnings: Buffer[] +} + +export class DockerJsonStreamParser { + private pending = '' + + push(chunk: string): DockerJsonMessage[] { + this.pending += chunk + const messages: DockerJsonMessage[] = [] + let newlineIndex = this.pending.indexOf('\n') + + while (newlineIndex >= 0) { + const line = this.pending.slice(0, newlineIndex) + this.pending = this.pending.slice(newlineIndex + 1) + const message = this.parseLine(line) + if (message) { + messages.push(message) + } + newlineIndex = this.pending.indexOf('\n') + } + + return messages + } + + flush(): DockerJsonMessage[] { + const line = this.pending + this.pending = '' + const message = this.parseLine(line) + return message ? [message] : [] + } + + private parseLine(line: string): DockerJsonMessage | undefined { + const trimmed = line.trim() + if (!trimmed) { + return undefined + } + + try { + return JSON.parse(trimmed) as DockerJsonMessage + } catch (ignore) { + return { + stream: `Cannot parse ${trimmed}`, + } + } + } +} + +export class DockerBuildOutputDecoder { + private displayedVertices = new Set() + private displayedErrors = new Set() + + decode(message: DockerJsonMessage): DockerJsonMessage[] { + const output: DockerJsonMessage[] = [] + + if (message.stream || message.error) { + output.push(message) + } + + if ( + message.id !== 'moby.buildkit.trace' || + typeof message.aux !== 'string' + ) { + return output + } + + try { + const status = decodeBuildKitStatus( + Buffer.from(message.aux, 'base64') + ) + + status.vertices.forEach((vertex) => { + const vertexId = vertex.digest || vertex.name + if ( + vertex.name && + vertexId && + !this.displayedVertices.has(vertexId) + ) { + this.displayedVertices.add(vertexId) + output.push({ stream: `${vertex.name}\n` }) + } + + if (vertex.error && !this.displayedErrors.has(vertex.error)) { + this.displayedErrors.add(vertex.error) + output.push({ + error: vertex.error, + errorDetail: { message: vertex.error }, + }) + } + }) + + status.logs.forEach((log) => { + const text = log.toString('utf8') + if (text) { + output.push({ stream: text }) + } + }) + + status.warnings.forEach((warning) => { + const text = warning.toString('utf8').trim() + if (text) { + output.push({ stream: `WARNING: ${text}\n` }) + } + }) + } catch (ignore) { + output.push({ stream: 'Cannot parse BuildKit build output\n' }) + } + + return output + } +} + +function decodeBuildKitStatus(buffer: Buffer): BuildKitStatus { + const status: BuildKitStatus = { + vertices: [], + logs: [], + warnings: [], + } + let offset = 0 + + while (offset < buffer.length) { + const tag = readVarint(buffer, offset) + offset = tag.offset + const fieldNumber = Math.floor(tag.value / 8) + const wireType = tag.value % 8 + + if (wireType === 2 && fieldNumber === 1) { + const value = readBytes(buffer, offset) + offset = value.offset + status.vertices.push(decodeVertex(value.value)) + } else if (wireType === 2 && fieldNumber === 3) { + const value = readBytes(buffer, offset) + offset = value.offset + const log = decodeVertexLog(value.value) + if (log) { + status.logs.push(log) + } + } else if (wireType === 2 && fieldNumber === 4) { + const value = readBytes(buffer, offset) + offset = value.offset + const warning = decodeVertexWarning(value.value) + if (warning) { + status.warnings.push(warning) + } + } else { + offset = skipField(buffer, offset, wireType) + } + } + + return status +} + +function decodeVertex(buffer: Buffer): BuildKitVertex { + const vertex: BuildKitVertex = { digest: '', name: '', error: '' } + let offset = 0 + + while (offset < buffer.length) { + const tag = readVarint(buffer, offset) + offset = tag.offset + const fieldNumber = Math.floor(tag.value / 8) + const wireType = tag.value % 8 + + if (wireType === 2 && [1, 3, 7].includes(fieldNumber)) { + const value = readBytes(buffer, offset) + offset = value.offset + const text = value.value.toString('utf8') + if (fieldNumber === 1) vertex.digest = text + if (fieldNumber === 3) vertex.name = text + if (fieldNumber === 7) vertex.error = text + } else { + offset = skipField(buffer, offset, wireType) + } + } + + return vertex +} + +function decodeVertexLog(buffer: Buffer): Buffer | undefined { + return decodeBytesField(buffer, 4) +} + +function decodeVertexWarning(buffer: Buffer): Buffer | undefined { + return decodeBytesField(buffer, 3) +} + +function decodeBytesField( + buffer: Buffer, + expectedFieldNumber: number +): Buffer | undefined { + let offset = 0 + + while (offset < buffer.length) { + const tag = readVarint(buffer, offset) + offset = tag.offset + const fieldNumber = Math.floor(tag.value / 8) + const wireType = tag.value % 8 + + if (wireType === 2 && fieldNumber === expectedFieldNumber) { + return readBytes(buffer, offset).value + } + + offset = skipField(buffer, offset, wireType) + } + + return undefined +} + +function readVarint(buffer: Buffer, startOffset: number): ProtobufValue { + let value = 0 + let multiplier = 1 + let offset = startOffset + + for (let byteCount = 0; byteCount < 10; byteCount++) { + if (offset >= buffer.length) { + throw new Error('Invalid protobuf varint') + } + + const byte = buffer[offset++] + if (multiplier <= 2 ** 49) { + value += (byte & 0x7f) * multiplier + } + if ((byte & 0x80) === 0) { + return { value, offset } + } + multiplier *= 128 + } + + throw new Error('Invalid protobuf varint') +} + +function readBytes(buffer: Buffer, startOffset: number): ProtobufBytes { + const length = readVarint(buffer, startOffset) + const endOffset = length.offset + length.value + if (endOffset > buffer.length) { + throw new Error('Invalid protobuf field length') + } + + return { + value: buffer.subarray(length.offset, endOffset), + offset: endOffset, + } +} + +function skipField( + buffer: Buffer, + startOffset: number, + wireType: number +): number { + if (wireType === 0) { + return readVarint(buffer, startOffset).offset + } + if (wireType === 1) { + return ensureOffset(buffer, startOffset + 8) + } + if (wireType === 2) { + return readBytes(buffer, startOffset).offset + } + if (wireType === 5) { + return ensureOffset(buffer, startOffset + 4) + } + + throw new Error(`Unsupported protobuf wire type ${wireType}`) +} + +function ensureOffset(buffer: Buffer, offset: number): number { + if (offset > buffer.length) { + throw new Error('Invalid protobuf field') + } + return offset +} diff --git a/src/utils/CaptainConstants.ts b/src/utils/CaptainConstants.ts index 0c33161..bea774c 100644 --- a/src/utils/CaptainConstants.ts +++ b/src/utils/CaptainConstants.ts @@ -17,7 +17,7 @@ const CONSTANT_FILE_OVERRIDE_USER = const configs = { publishedNameOnDockerHub: 'caprover/caprover', - version: '1.15.2', + version: '1.15.3', defaultMaxLogSize: '512m', diff --git a/tests/DockerBuildOutput.test.ts b/tests/DockerBuildOutput.test.ts new file mode 100644 index 0000000..2da3f6c --- /dev/null +++ b/tests/DockerBuildOutput.test.ts @@ -0,0 +1,157 @@ +import { + DockerBuildOutputDecoder, + DockerJsonStreamParser, +} from '../src/docker/DockerBuildOutput' + +function varint(value: number): Buffer { + const bytes: number[] = [] + do { + const remaining = Math.floor(value / 128) + bytes.push(value % 128 | (remaining ? 0x80 : 0)) + value = remaining + } while (value) + return Buffer.from(bytes) +} + +function bytesField(fieldNumber: number, value: string | Buffer): Buffer { + const data = Buffer.isBuffer(value) ? value : Buffer.from(value) + return Buffer.concat([ + varint(fieldNumber * 8 + 2), + varint(data.length), + data, + ]) +} + +test('buffers split JSON messages and parses multiple messages', () => { + const parser = new DockerJsonStreamParser() + + expect(parser.push('{"stream":"hel')).toEqual([]) + expect(parser.push('lo"}\n{"error":"failed"}\n')).toEqual([ + { stream: 'hello' }, + { error: 'failed' }, + ]) +}) + +test('keeps buffers isolated and flushes the final message', () => { + const first = new DockerJsonStreamParser() + const second = new DockerJsonStreamParser() + + first.push('{"stream":"first"') + second.push('{"stream":"second"}') + + expect(first.push('}\n')).toEqual([{ stream: 'first' }]) + expect(second.flush()).toEqual([{ stream: 'second' }]) +}) + +test('reports malformed JSON without interrupting later messages', () => { + const parser = new DockerJsonStreamParser() + + expect(parser.push('not-json\n{"stream":"still running"}\n')).toEqual([ + { stream: 'Cannot parse not-json' }, + { stream: 'still running' }, + ]) +}) + +test('preserves traditional builder output', () => { + const decoder = new DockerBuildOutputDecoder() + const message = { stream: 'Step 1/1\n' } + + expect(decoder.decode(message)).toEqual([message]) +}) + +test('decodes BuildKit vertices, logs, and warnings', () => { + const vertex = Buffer.concat([ + bytesField(1, 'sha256:vertex'), + bytesField(3, '[1/1] RUN npm test'), + ]) + const log = Buffer.concat([ + bytesField(1, 'sha256:vertex'), + bytesField(4, 'tests passed\n'), + ]) + const warning = bytesField(3, 'deprecated instruction') + const status = Buffer.concat([ + bytesField(1, vertex), + bytesField(3, log), + bytesField(4, warning), + ]) + const decoder = new DockerBuildOutputDecoder() + + expect( + decoder.decode({ + id: 'moby.buildkit.trace', + aux: status.toString('base64'), + }) + ).toEqual([ + { stream: '[1/1] RUN npm test\n' }, + { stream: 'tests passed\n' }, + { stream: 'WARNING: deprecated instruction\n' }, + ]) + + expect( + decoder.decode({ + id: 'moby.buildkit.trace', + aux: bytesField(1, vertex).toString('base64'), + }) + ).toEqual([]) +}) + +test('decodes BuildKit vertex errors once with error details', () => { + const vertex = Buffer.concat([ + bytesField(1, 'sha256:failed-vertex'), + bytesField(3, '[1/1] RUN exit 1'), + bytesField(7, 'process exited with code 1'), + ]) + const message = { + id: 'moby.buildkit.trace', + aux: bytesField(1, vertex).toString('base64'), + } + const decoder = new DockerBuildOutputDecoder() + + expect(decoder.decode(message)).toEqual([ + { stream: '[1/1] RUN exit 1\n' }, + { + error: 'process exited with code 1', + errorDetail: { message: 'process exited with code 1' }, + }, + ]) + expect(decoder.decode(message)).toEqual([]) +}) + +test('reports malformed BuildKit protobuf output', () => { + const decoder = new DockerBuildOutputDecoder() + + expect( + decoder.decode({ + id: 'moby.buildkit.trace', + aux: Buffer.from([0x0a, 0x05, 0x01]).toString('base64'), + }) + ).toEqual([{ stream: 'Cannot parse BuildKit build output\n' }]) +}) + +test('ignores unrelated aux messages', () => { + const decoder = new DockerBuildOutputDecoder() + + expect( + decoder.decode({ id: 'other.message', aux: 'not-protobuf' }) + ).toEqual([]) +}) + +test('skips 10-byte protobuf varints without dropping later output', () => { + const fullWidthVarint = Buffer.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, + ]) + const log = Buffer.concat([ + Buffer.from([0x08]), + fullWidthVarint, + bytesField(4, 'still visible\n'), + ]) + const status = bytesField(3, log) + const decoder = new DockerBuildOutputDecoder() + + expect( + decoder.decode({ + id: 'moby.buildkit.trace', + aux: status.toString('base64'), + }) + ).toEqual([{ stream: 'still visible\n' }]) +})