This commit is contained in:
Jarek Krochmalski
2026-03-03 10:17:41 +01:00
parent 0c894d906f
commit 464fcb4231
4 changed files with 69 additions and 43 deletions
+6 -3
View File
@@ -23,7 +23,7 @@ RUN apk add --no-cache curl unzip \
| tar -xz --strip-components=1 -C /usr/local/bin \
&& chmod +x /usr/local/bin/apko
# Generate apko.yaml — Node.js instead of Bun
# Generate apko.yaml — Node.js binary comes from node:24-slim, not Wolfi
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
&& printf '%s\n' \
"contents:" \
@@ -36,7 +36,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - ca-certificates" \
" - busybox" \
" - tzdata" \
" - nodejs-24" \
" - docker-cli" \
" - docker-compose" \
" - docker-cli-buildx" \
@@ -66,7 +65,7 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
# -----------------------------------------------------------------------------
# Stage 2: Application Builder (pure Node.js)
# -----------------------------------------------------------------------------
FROM node:24-slim AS app-builder
FROM --platform=$TARGETPLATFORM node:24-slim AS app-builder
WORKDIR /app
@@ -104,6 +103,10 @@ FROM scratch
# Install custom Wolfi OS with Node.js
COPY --from=os-builder /work/rootfs/ /
# Copy Node.js binary from official node:24-slim (platform-correct, conservative CPU baseline)
# Wolfi's nodejs-24 targets ARMv8.1+ which causes SIGILL on Cortex-A53 (Raspberry Pi 3+)
COPY --from=app-builder /usr/local/bin/node /usr/local/bin/node
# Copy libnss_wrapper for git SSH with arbitrary UIDs
COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so
+12 -2
View File
@@ -2616,6 +2616,9 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
return null;
}
// Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse)
await drainResponse(challengeResponse);
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
@@ -2659,7 +2662,9 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error('[Registry] Failed to get bearer token:', errorMsg);
const cause = (e as any)?.cause;
const causeMsg = cause ? ` (cause: ${cause})` : '';
console.error('[Registry] Failed to get bearer token:', errorMsg + causeMsg);
return null;
}
}
@@ -2725,6 +2730,9 @@ export async function getRegistryAuthHeader(
return null;
}
// Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse)
await drainResponse(challengeResponse);
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
@@ -2767,7 +2775,9 @@ export async function getRegistryAuthHeader(
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error('[Registry] Failed to get auth header:', errorMsg);
const cause = (e as any)?.cause;
const causeMsg = cause ? ` (cause: ${cause})` : '';
console.error('[Registry] Failed to get auth header:', errorMsg + causeMsg);
return null;
}
}
+26 -4
View File
@@ -414,6 +414,11 @@ function cleanupRecentEvents(): void {
async function sendEnvironmentConfigs(): Promise<void> {
const environments = await getEnvironments();
const activeIds = new Set<number>();
const lines: string[] = [];
const enqueue = (msg: Record<string, unknown>) => {
lines.push(JSON.stringify(msg));
};
for (const env of environments) {
// Skip hawser-edge (events come via WebSocket)
@@ -446,7 +451,7 @@ async function sendEnvironmentConfigs(): Promise<void> {
// Only send if env has metrics or activity collection enabled
if (env.collectMetrics === false && env.collectActivity === false) continue;
sendToGo({
enqueue({
type: 'configure',
envId: env.id,
name: env.name,
@@ -461,7 +466,7 @@ async function sendEnvironmentConfigs(): Promise<void> {
// Remove envs that are no longer active
for (const envId of configuredEnvs) {
if (!activeIds.has(envId)) {
sendToGo({ type: 'remove', envId });
enqueue({ type: 'remove', envId });
configuredEnvs.delete(envId);
envNames.delete(envId);
}
@@ -469,11 +474,18 @@ async function sendEnvironmentConfigs(): Promise<void> {
// Send settings
const metricsInterval = await getMetricsCollectionInterval();
sendToGo({ type: 'set_metrics_interval', intervalMs: metricsInterval });
enqueue({ type: 'set_metrics_interval', intervalMs: metricsInterval });
const eventMode = await getEventCollectionMode();
const pollInterval = await getEventPollInterval();
sendToGo({ type: 'set_event_mode', mode: eventMode, pollIntervalMs: pollInterval });
enqueue({ type: 'set_event_mode', mode: eventMode, pollIntervalMs: pollInterval });
// Single atomic write — avoids pipe backpressure on low-memory ARM devices
// where multiple rapid writes can overflow small OS pipe buffers (4-16KB on
// some ARM Linux configs) before Go has drained them.
if (lines.length > 0 && proc?.stdin?.writable) {
proc.stdin.write(lines.join('\n') + '\n');
}
}
// ---------------------------------------------------------------------------
@@ -544,6 +556,16 @@ export async function startSubprocesses(): Promise<void> {
stdio: ['pipe', 'pipe', 'inherit']
});
// Prevent unhandled 'error' events on stdin from destroying the pipe.
// Without this, any write error (e.g. EPIPE on a momentarily full pipe buffer
// on low-memory systems) destroys the stream, sending EOF to Go and causing
// it to exit — which looks like a mysterious restart loop on Raspberry Pi.
proc.stdin?.on('error', (err: NodeJS.ErrnoException) => {
if (!isShuttingDown) {
console.error('[SubprocessManager] stdin pipe error:', err.message);
}
});
// Start reading stdout
readStdout();
+25 -34
View File
@@ -588,46 +588,37 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
// Continuously process all sources
console.log('[merged-logs] Starting processing loop');
let loopCount = 0;
while (!controllerClosed) {
const activeSources = sources.filter(s => !s.done && s.reader);
if (activeSources.length === 0) {
// Each source streams independently — no lockstep polling
console.log(`[merged-logs] Starting ${sources.length} independent read loops`);
let endedCount = 0;
const checkAllDone = () => {
endedCount++;
if (endedCount >= sources.length) {
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'all streams ended' })}\n\n`);
break;
}
if (loopCount === 0) {
console.log(`[merged-logs] Processing ${activeSources.length} active sources, first read...`);
}
loopCount++;
await Promise.all(activeSources.map(processSource));
// Small delay to prevent tight loop
await new Promise(resolve => setTimeout(resolve, 10));
}
// Cleanup readers
for (const source of sources) {
if (source.reader) {
try {
await source.reader.cancel().catch(() => {});
source.reader.releaseLock();
} catch {
// Ignore
if (!controllerClosed) {
try { controller.close(); } catch { /* Already closed */ }
}
}
}
};
if (!controllerClosed) {
const runSource = async (source: ContainerLogSource) => {
try {
controller.close();
} catch {
// Already closed
while (!controllerClosed && !source.done) {
await processSource(source);
}
} finally {
if (source.reader) {
try {
await source.reader.cancel().catch(() => {});
source.reader.releaseLock();
} catch { /* Ignore */ }
}
checkAllDone();
}
}
};
await Promise.all(sources.map(runSource));
},
cancel() {
controllerClosed = true;