mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.20
This commit is contained in:
+6
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user