Merge pull request #2465 from caprover/release
Run build / build (push) Canceled after 0s
Run formatter / check-code-formatting (push) Canceled after 0s
Run lint / run-lint (push) Canceled after 0s
Build and push the edge image / run-pre-checks (push) Canceled after 0s
Build and push the edge image / build-publish-docker-hub (push) Canceled after 0s

Release back merge
This commit is contained in:
Kasra Bigdeli
2026-08-20 14:30:54 -04:00
committed by GitHub
6 changed files with 523 additions and 74 deletions
+5
View File
@@ -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)
+1 -1
View File
@@ -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)
+63 -72
View File
@@ -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<void>(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<void>(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<void>(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)
+296
View File
@@ -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<string>()
private displayedErrors = new Set<string>()
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
}
+1 -1
View File
@@ -17,7 +17,7 @@ const CONSTANT_FILE_OVERRIDE_USER =
const configs = {
publishedNameOnDockerHub: 'caprover/caprover',
version: '1.15.2',
version: '1.15.3',
defaultMaxLogSize: '512m',
+157
View File
@@ -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' }])
})