mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 03:20:43 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9bc2a13d1 | |||
| a84c11113c | |||
| 464fcb4231 | |||
| 0c894d906f | |||
| 1c16efd872 | |||
| 77ec974d09 | |||
| e9e521656c | |||
| c618328d83 |
+9
-5
@@ -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
|
||||
|
||||
@@ -90,10 +89,11 @@ RUN rm -rf node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
# Build Go collector
|
||||
FROM golang:1.24 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
RUN cd collector && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /app/bin/collection-worker .
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
|
||||
@@ -103,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
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# =============================================================================
|
||||
# Dockhand Docker Image - Baseline Build (Alpine/musl, amd64 only)
|
||||
# =============================================================================
|
||||
# For older x86_64 hardware without AVX2/SSE4.2 (TrueNAS, older Intel Atom/Celeron)
|
||||
# Uses node:24-alpine (musl libc) compiled conservatively for all x86_64 CPUs.
|
||||
# The Wolfi/glibc build crashes with SIGILL on CPUs that don't support the
|
||||
# microarchitecture level Wolfi packages are compiled for.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Application Builder (Alpine - musl-compatible native addons)
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTANT: Must use alpine builder so native addons (better-sqlite3) are
|
||||
# compiled against musl libc, not glibc. Cross-ABI copies would not work.
|
||||
FROM node:24-alpine AS app-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git curl python3 make g++
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production dependencies only (rebuilds native addons against musl)
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Go Collector Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM golang:1.24 AS go-builder
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Alpine-based runtime)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:24-alpine
|
||||
|
||||
# Install runtime packages
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
docker-cli-buildx \
|
||||
sqlite \
|
||||
postgresql-client \
|
||||
git \
|
||||
openssh \
|
||||
curl \
|
||||
tini \
|
||||
su-exec \
|
||||
libstdc++
|
||||
|
||||
# Create docker compose plugin symlink
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set up environment variables
|
||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOST=0.0.0.0 \
|
||||
DATA_DIR=/app/data \
|
||||
HOME=/home/dockhand \
|
||||
PUID=1001 \
|
||||
PGID=1001
|
||||
|
||||
# Copy application files with correct ownership
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/server.js ./
|
||||
|
||||
# Copy Go collector binary
|
||||
COPY --from=go-builder --chown=dockhand:dockhand /app/bin/collection-worker ./bin/collection-worker
|
||||
|
||||
# Copy database migrations
|
||||
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
|
||||
COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts
|
||||
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "/app/server.js"]
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Dockhand Docker Entrypoint (Node.js)
|
||||
# === Configuration ===
|
||||
PUID=${PUID:-1001}
|
||||
PGID=${PGID:-1001}
|
||||
|
||||
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||
DEFAULT_CMD="node --expose-gc /app/server.js"
|
||||
else
|
||||
DEFAULT_CMD="node /app/server.js"
|
||||
fi
|
||||
|
||||
# === Detect if running as root ===
|
||||
RUNNING_AS_ROOT=false
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
RUNNING_AS_ROOT=true
|
||||
fi
|
||||
|
||||
# === Non-root mode (user: directive in compose) ===
|
||||
if [ "$RUNNING_AS_ROOT" = "false" ]; then
|
||||
echo "Running as user $(id -u):$(id -g) (set via container user directive)"
|
||||
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
if [ ! -d "$DATA_DIR/db" ]; then
|
||||
echo "Creating database directory at $DATA_DIR/db"
|
||||
mkdir -p "$DATA_DIR/db" 2>/dev/null || {
|
||||
echo "ERROR: Cannot create $DATA_DIR/db directory"
|
||||
echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [ ! -d "$DATA_DIR/stacks" ]; then
|
||||
mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
if test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
if [ -z "$DOCKHAND_HOSTNAME" ]; then
|
||||
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$DETECTED_HOSTNAME" ]; then
|
||||
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
|
||||
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
|
||||
echo "WARNING: Docker socket not readable by user $(id -u)"
|
||||
echo "Add --group-add $SOCKET_GID to your docker run command"
|
||||
fi
|
||||
else
|
||||
echo "No Docker socket found at $SOCKET_PATH"
|
||||
echo "Configure Docker environments via the web UI (Settings > Environments)"
|
||||
fi
|
||||
|
||||
if [ "$1" = "" ]; then
|
||||
exec $DEFAULT_CMD
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
# === User Setup ===
|
||||
if [ "$PUID" = "0" ]; then
|
||||
echo "Running as root user (PUID=0)"
|
||||
RUN_USER="root"
|
||||
elif [ "$RUNNING_AS_ROOT" = "true" ] && [ "$PUID" = "1001" ] && [ "$PGID" = "1001" ]; then
|
||||
echo "Running as root user"
|
||||
RUN_USER="root"
|
||||
else
|
||||
RUN_USER="dockhand"
|
||||
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
|
||||
echo "Configuring user with PUID=$PUID PGID=$PGID"
|
||||
|
||||
deluser dockhand 2>/dev/null || true
|
||||
delgroup dockhand 2>/dev/null || true
|
||||
|
||||
SKIP_USER_CREATE=false
|
||||
EXISTING=$(awk -F: -v uid="$PUID" '$3 == uid { print $1 }' /etc/passwd)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001."
|
||||
PUID=1001
|
||||
fi
|
||||
|
||||
TARGET_GROUP=$(awk -F: -v gid="$PGID" '$3 == gid { print $1 }' /etc/group)
|
||||
if [ -z "$TARGET_GROUP" ]; then
|
||||
addgroup -g "$PGID" dockhand
|
||||
TARGET_GROUP="dockhand"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_USER_CREATE" = "false" ]; then
|
||||
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
|
||||
fi
|
||||
fi
|
||||
|
||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
||||
if [ "$RUN_USER" = "dockhand" ]; then
|
||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Docker Socket Access ===
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
if [ "$RUN_USER" != "root" ]; then
|
||||
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$SOCKET_GID" ]; then
|
||||
if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket GID: $SOCKET_GID - adding $RUN_USER to docker group..."
|
||||
|
||||
DOCKER_GROUP=$(awk -F: -v gid="$SOCKET_GID" '$3 == gid { print $1 }' /etc/group)
|
||||
if [ -z "$DOCKER_GROUP" ]; then
|
||||
DOCKER_GROUP="docker"
|
||||
addgroup -g "$SOCKET_GID" "$DOCKER_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
addgroup "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || \
|
||||
adduser "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || true
|
||||
|
||||
if su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
else
|
||||
echo "WARNING: Could not grant Docker socket access to $RUN_USER"
|
||||
echo "Try running container with: --group-add $SOCKET_GID"
|
||||
fi
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$DOCKHAND_HOSTNAME" ]; then
|
||||
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$DETECTED_HOSTNAME" ]; then
|
||||
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
|
||||
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
else
|
||||
echo "Using configured hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
else
|
||||
echo "No local Docker socket mounted (this is normal when using socket-proxy or remote Docker)"
|
||||
echo "Configure your Docker environment via the web UI: Settings > Environments"
|
||||
fi
|
||||
|
||||
# === Run Application ===
|
||||
if [ "$RUN_USER" = "root" ]; then
|
||||
if [ "$1" = "" ]; then
|
||||
exec $DEFAULT_CMD
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
else
|
||||
echo "Running as user: $RUN_USER"
|
||||
if [ "$1" = "" ]; then
|
||||
exec su-exec "$RUN_USER" $DEFAULT_CMD
|
||||
else
|
||||
exec su-exec "$RUN_USER" "$@"
|
||||
fi
|
||||
fi
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.20",
|
||||
"date": "2026-03-02",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "regression on Synology DSM" },
|
||||
{ "type": "fix", "text": "Fix ARM64 regression: Go collector crashing on Raspberry Pi and other ARM devices" },
|
||||
{ "type": "fix", "text": "autoupdate hangs on \"waiting for Dockhand\"" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.20"
|
||||
},
|
||||
{
|
||||
"version": "1.0.19",
|
||||
"comingSoon": true,
|
||||
"date": "2026-03-01",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Inline logs panel on stacks page — view container logs without leaving the page" },
|
||||
{ "type": "feature", "text": "Make ports column sortable in containers grid" },
|
||||
|
||||
+13
-38
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
|
||||
/**
|
||||
* Custom error for when an environment is not found.
|
||||
@@ -1664,42 +1665,6 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-diff two objects recursively, returning all paths that differ.
|
||||
*/
|
||||
export function deepDiff(a: any, b: any, path = ''): string[] {
|
||||
const diffs: string[] = [];
|
||||
|
||||
if (a === b) return diffs;
|
||||
if (a === null || b === null || typeof a !== typeof b) {
|
||||
diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (typeof a !== 'object') {
|
||||
if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aStr = JSON.stringify(a);
|
||||
const bStr = JSON.stringify(b);
|
||||
if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
|
||||
for (const key of allKeys) {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
if (!(key in a)) {
|
||||
diffs.push(`${childPath}: <missing> → ${JSON.stringify(b[key])}`);
|
||||
} else if (!(key in b)) {
|
||||
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
||||
} else {
|
||||
diffs.push(...deepDiff(a[key], b[key], childPath));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a container using full Config/HostConfig passthrough from inspect data.
|
||||
@@ -2651,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);
|
||||
@@ -2694,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;
|
||||
}
|
||||
}
|
||||
@@ -2760,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);
|
||||
@@ -2802,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
import { DockerConnectionError } from './docker';
|
||||
import { normalizeStackName } from '$lib/utils/stack-name';
|
||||
|
||||
// Compose file patterns to detect (in order of priority - prefer new style first)
|
||||
const COMPOSE_PATTERNS = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
|
||||
@@ -41,16 +43,8 @@ export interface ScanResult {
|
||||
errors: { path: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
|
||||
*/
|
||||
export function normalizeStackName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
// normalizeStackName re-exported for backward compatibility
|
||||
export { normalizeStackName } from '$lib/utils/stack-name';
|
||||
|
||||
/**
|
||||
* Check if a file looks like a compose file (contains 'services:' key)
|
||||
@@ -488,8 +482,11 @@ export async function detectRunningStacks(
|
||||
runningStacksMap.set(stack.name, existing);
|
||||
}
|
||||
} catch (error) {
|
||||
// Environment might be offline - skip silently
|
||||
console.warn(`[Stack Scanner] Failed to query environment ${env.name}:`, error);
|
||||
if (error instanceof DockerConnectionError) {
|
||||
console.warn(`[Stack Scanner] Skipping offline environment ${env.name}: ${error.message}`);
|
||||
} else {
|
||||
console.warn(`[Stack Scanner] Failed to query environment ${env.name}:`, error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
+100
-2
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Provides compose-first stack operations for internal, git, and external stacks.
|
||||
* All lifecycle operations use docker compose commands.
|
||||
* v1.0.20
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
@@ -138,6 +139,10 @@ const stackLocks = new Map<string, Promise<void>>();
|
||||
// Track active TLS temp directories for cleanup on unexpected process exit
|
||||
const activeTlsDirs = new Set<string>();
|
||||
|
||||
// Cache of envId → daemon max API version (e.g. "1.43")
|
||||
// Populated lazily to avoid CLI/daemon version mismatch on older Docker hosts (e.g. Synology)
|
||||
const dockerApiVersionCache = new Map<string, string>();
|
||||
|
||||
// Register cleanup handlers once at module load
|
||||
if (typeof process !== 'undefined') {
|
||||
const cleanupTlsDirs = () => {
|
||||
@@ -153,6 +158,85 @@ if (typeof process !== 'undefined') {
|
||||
process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the Docker daemon's maximum supported API version for a given environment.
|
||||
* Used to set DOCKER_API_VERSION when spawning docker compose, preventing version mismatch
|
||||
* errors on older Docker hosts (e.g. Synology DSM).
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Try Dockhand's HTTP API call to the daemon (works for all environment types)
|
||||
* 2. Fall back to `docker version` CLI command (works for local socket connections)
|
||||
*/
|
||||
async function getDockerApiVersionForCli(envId: number | null | undefined): Promise<string | undefined> {
|
||||
const key = String(envId ?? 'local');
|
||||
if (dockerApiVersionCache.has(key)) return dockerApiVersionCache.get(key);
|
||||
|
||||
// Strategy 1: Use Dockhand's HTTP API to query the daemon
|
||||
if (envId) {
|
||||
try {
|
||||
const { getDockerVersion } = await import('./docker.js');
|
||||
const version = await getDockerVersion(envId) as { ApiVersion?: string };
|
||||
const apiVersion: string | undefined = version?.ApiVersion;
|
||||
if (apiVersion) {
|
||||
console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via HTTP API)`);
|
||||
dockerApiVersionCache.set(key, apiVersion);
|
||||
return apiVersion;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Docker API Version] HTTP API query failed for env ${key}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to `docker version` CLI command
|
||||
// This handles local socket connections where envId is null and also
|
||||
// cases where the HTTP API query fails (e.g. daemon quirks on Synology)
|
||||
try {
|
||||
const apiVersion = await getDockerApiVersionViaCli();
|
||||
if (apiVersion) {
|
||||
console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via CLI)`);
|
||||
dockerApiVersionCache.set(key, apiVersion);
|
||||
return apiVersion;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Docker API Version] CLI query failed for env ${key}: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
console.warn(`[Docker API Version] Could not detect daemon API version for env ${key}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Docker daemon's API version using the `docker version` CLI command.
|
||||
* This is a fallback for when the HTTP API query fails or envId is null.
|
||||
*/
|
||||
function getDockerApiVersionViaCli(): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = nodeSpawn('docker', ['version', '--format', '{{.Server.APIVersion}}'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
// Use the minimum Docker API version (1.25) for this probe command.
|
||||
// This ensures the probe itself doesn't fail due to the version mismatch
|
||||
// we're trying to detect.
|
||||
env: {
|
||||
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
||||
DOCKER_API_VERSION: '1.25'
|
||||
}
|
||||
});
|
||||
let stdout = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', () => {}); // drain stderr to prevent pipe buffer blocking
|
||||
proc.on('close', (code) => {
|
||||
const version = stdout.trim();
|
||||
if (code === 0 && /^\d+\.\d+$/.test(version)) {
|
||||
resolve(version);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
proc.on('error', () => resolve(undefined));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with exclusive lock on a stack.
|
||||
* Prevents race conditions when multiple operations target the same stack.
|
||||
@@ -725,7 +809,7 @@ export async function saveStackComposeFile(
|
||||
* Login to all configured Docker registries before running compose commands.
|
||||
* This ensures that `docker compose up` can pull images from private registries.
|
||||
*/
|
||||
async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Promise<void> {
|
||||
async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', apiVersion?: string): Promise<void> {
|
||||
const { getRegistries } = await import('./db.js');
|
||||
const registries = await getRegistries();
|
||||
|
||||
@@ -737,6 +821,10 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
}
|
||||
// Cap Docker CLI API version to prevent version mismatch errors
|
||||
if (apiVersion) {
|
||||
spawnEnv.DOCKER_API_VERSION = apiVersion;
|
||||
}
|
||||
|
||||
for (const reg of registries) {
|
||||
if (!reg.username || !reg.password) {
|
||||
@@ -909,6 +997,15 @@ async function executeLocalCompose(
|
||||
spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST;
|
||||
}
|
||||
|
||||
// Auto-cap Docker CLI API version to the daemon's max supported version.
|
||||
// This fixes compatibility with older Docker daemons (e.g. Synology DSM) that
|
||||
// reject newer client versions. DOCKER_API_VERSION env var overrides this if set.
|
||||
const daemonApiVersion = process.env.DOCKER_API_VERSION
|
||||
?? await getDockerApiVersionForCli(envId);
|
||||
if (daemonApiVersion) {
|
||||
spawnEnv.DOCKER_API_VERSION = daemonApiVersion;
|
||||
}
|
||||
|
||||
// Check if .env file exists on disk (for legacy support decision)
|
||||
const defaultEnvPath = join(stackDir, '.env');
|
||||
const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
|
||||
@@ -1065,6 +1162,7 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} Working directory:`, stackDir);
|
||||
console.log(`${logPrefix} Compose file:`, composeFile);
|
||||
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
|
||||
console.log(`${logPrefix} DOCKER_API_VERSION:`, daemonApiVersion || '(not set - using CLI default)');
|
||||
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
|
||||
console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false);
|
||||
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
|
||||
@@ -1075,7 +1173,7 @@ async function executeLocalCompose(
|
||||
|
||||
// Login to registries before pulling images
|
||||
if (operation === 'up' || operation === 'pull') {
|
||||
await loginToRegistries(dockerHost, logPrefix);
|
||||
await loginToRegistries(dockerHost, logPrefix, daemonApiVersion);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -84,6 +84,10 @@ let lineBuffer: Buffer = Buffer.alloc(0);
|
||||
let restartDelay = 1000;
|
||||
const MAX_RESTART_DELAY = 60000;
|
||||
|
||||
// Ready-signal plumbing: resolved when Go sends {"type":"ready"}
|
||||
let readyResolve: (() => void) | null = null;
|
||||
let readyPromise: Promise<void> | null = null;
|
||||
|
||||
// Dedup cache for events
|
||||
const recentEvents: Map<string, number> = new Map();
|
||||
// Disk warning cooldown per env
|
||||
@@ -147,6 +151,8 @@ function handleLine(line: string): void {
|
||||
case 'ready':
|
||||
console.log('[SubprocessManager] Go worker ready');
|
||||
restartDelay = 1000; // Reset backoff on successful start
|
||||
readyResolve?.();
|
||||
readyResolve = null;
|
||||
break;
|
||||
|
||||
case 'metrics':
|
||||
@@ -408,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)
|
||||
@@ -440,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,
|
||||
@@ -455,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);
|
||||
}
|
||||
@@ -463,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');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -531,15 +549,32 @@ export async function startSubprocesses(): Promise<void> {
|
||||
const workerPath = resolveWorkerPath();
|
||||
console.log(`[SubprocessManager] Starting Go worker (${workerPath})...`);
|
||||
|
||||
// Set up ready promise BEFORE spawning so we don't miss the signal
|
||||
readyPromise = new Promise<void>(resolve => { readyResolve = resolve; });
|
||||
|
||||
proc = spawn(workerPath, [], {
|
||||
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();
|
||||
|
||||
// Handle process exit
|
||||
proc.on('exit', (code) => {
|
||||
// Clear stale ready promise if process exits before signalling ready
|
||||
readyResolve = null;
|
||||
readyPromise = null;
|
||||
|
||||
if (!isShuttingDown) {
|
||||
console.warn(`[SubprocessManager] Go worker exited with code ${code}, restarting in ${restartDelay / 1000}s...`);
|
||||
proc = null;
|
||||
@@ -554,8 +589,18 @@ export async function startSubprocesses(): Promise<void> {
|
||||
proc = null;
|
||||
});
|
||||
|
||||
// Wait a moment for the process to start, then send configs
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Wait for Go to signal it's ready and reading stdin, then send configs.
|
||||
// This fixes a race on DietPi where stdin closes transiently before the
|
||||
// old blind 100ms wait ends, causing configure messages to be silently dropped.
|
||||
try {
|
||||
await Promise.race([
|
||||
readyPromise,
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
|
||||
]);
|
||||
} catch {
|
||||
console.warn('[SubprocessManager] Go worker ready timeout, sending configs anyway');
|
||||
}
|
||||
readyPromise = null;
|
||||
await sendEnvironmentConfigs();
|
||||
|
||||
// Start dedup cleanup interval
|
||||
|
||||
@@ -185,6 +185,44 @@ function formatValue(val: any): any {
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-diff two objects recursively, returning all paths that differ.
|
||||
* Used for comparing container inspect snapshots before and after recreation.
|
||||
*/
|
||||
export function deepDiff(a: any, b: any, path = ''): string[] {
|
||||
const diffs: string[] = [];
|
||||
|
||||
if (a === b) return diffs;
|
||||
if (a === null || b === null || typeof a !== typeof b) {
|
||||
diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (typeof a !== 'object') {
|
||||
if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aStr = JSON.stringify(a);
|
||||
const bStr = JSON.stringify(b);
|
||||
if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
|
||||
for (const key of allKeys) {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
if (!(key in a)) {
|
||||
diffs.push(`${childPath}: <missing> → ${JSON.stringify(b[key])}`);
|
||||
} else if (!(key in b)) {
|
||||
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
||||
} else {
|
||||
diffs.push(...deepDiff(a[key], b[key], childPath));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field name for display (camelCase to Title Case)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
|
||||
*/
|
||||
export function normalizeStackName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* Check if environment is edge mode
|
||||
@@ -74,78 +74,74 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Check if this is an edge environment
|
||||
const edgeCheck = await isEdgeMode(envId);
|
||||
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
return createJobResponse(async (send) => {
|
||||
const sendData = (data: unknown) => {
|
||||
send('progress', data);
|
||||
};
|
||||
|
||||
const sendData = (data: unknown) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
/**
|
||||
* Handle scan-on-pull after image is pulled
|
||||
*/
|
||||
const handleScanOnPull = async () => {
|
||||
if (skipScanOnPull) return;
|
||||
|
||||
/**
|
||||
* Handle scan-on-pull after image is pulled
|
||||
*/
|
||||
const handleScanOnPull = async () => {
|
||||
if (skipScanOnPull) return;
|
||||
const { scanner } = await getScannerSettings(envId);
|
||||
if (scanner !== 'none') {
|
||||
sendData({ status: 'scanning', message: 'Starting vulnerability scan...' });
|
||||
|
||||
const { scanner } = await getScannerSettings(envId);
|
||||
if (scanner !== 'none') {
|
||||
sendData({ status: 'scanning', message: 'Starting vulnerability scan...' });
|
||||
try {
|
||||
const results = await scanImage(image, envId, (progress) => {
|
||||
sendData({ status: 'scan-progress', ...progress });
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await scanImage(image, envId, (progress) => {
|
||||
sendData({ status: 'scan-progress', ...progress });
|
||||
});
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envId ?? null,
|
||||
imageId: result.imageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envId ?? null,
|
||||
imageId: result.imageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
|
||||
sendData({
|
||||
status: 'scan-complete',
|
||||
message: `Scan complete - found ${totalVulns} vulnerabilities`,
|
||||
results
|
||||
});
|
||||
} catch (scanError) {
|
||||
console.error('Scan-on-pull failed:', scanError);
|
||||
sendData({
|
||||
status: 'scan-error',
|
||||
error: scanError instanceof Error ? scanError.message : String(scanError)
|
||||
});
|
||||
}
|
||||
|
||||
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
|
||||
sendData({
|
||||
status: 'scan-complete',
|
||||
message: `Scan complete - found ${totalVulns} vulnerabilities`,
|
||||
results
|
||||
});
|
||||
} catch (scanError) {
|
||||
console.error('Scan-on-pull failed:', scanError);
|
||||
sendData({
|
||||
status: 'scan-error',
|
||||
error: scanError instanceof Error ? scanError.message : String(scanError)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Run operation in background
|
||||
(async () => {
|
||||
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
|
||||
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
sendData({ status: 'error', error: 'Edge agent not connected' });
|
||||
failJob(job, 'Edge agent not connected');
|
||||
return;
|
||||
send('result', { status: 'error', error: 'Edge agent not connected' });
|
||||
throw new Error('Edge agent not connected');
|
||||
}
|
||||
|
||||
const pullUrl = buildPullUrl(image);
|
||||
const authHeaders = await buildRegistryAuthHeader(image);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId!,
|
||||
'POST',
|
||||
@@ -173,14 +169,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
onEnd: async () => {
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
completeJob(job, { status: 'complete' });
|
||||
send('result', { status: 'complete' });
|
||||
resolve();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge pull error:', error);
|
||||
sendData({ status: 'error', error });
|
||||
failJob(job, error);
|
||||
resolve();
|
||||
send('result', { status: 'error', error });
|
||||
reject(new Error(error));
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
@@ -198,17 +194,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
completeJob(job, { status: 'complete' });
|
||||
send('result', { status: 'complete' });
|
||||
} catch (error) {
|
||||
console.error('Error pulling image:', error);
|
||||
const errMsg = String(error);
|
||||
sendData({ status: 'error', error: errMsg });
|
||||
failJob(job, errMsg);
|
||||
send('result', { status: 'error', error: errMsg });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
}, request);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Helper to convert ScanResult to database format
|
||||
function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
@@ -24,7 +24,7 @@ function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// POST - Start a scan (returns { jobId } for progress polling)
|
||||
// POST - Start a scan (returns { jobId } for progress polling, or synchronous JSON for Accept: application/json)
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -43,14 +43,11 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
return createJobResponse(async (send) => {
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
send('progress', progress);
|
||||
};
|
||||
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
appendLine(job, { data: progress });
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
|
||||
|
||||
@@ -67,8 +64,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
result: results[0],
|
||||
results: results // Include all scanner results
|
||||
};
|
||||
sendProgress(completeProgress);
|
||||
completeJob(job, completeProgress);
|
||||
send('result', completeProgress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorProgress: ScanProgress = {
|
||||
@@ -76,14 +72,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
message: `Scan failed: ${errorMsg}`,
|
||||
error: errorMsg
|
||||
};
|
||||
sendProgress(errorProgress);
|
||||
failJob(job, errorMsg);
|
||||
send('result', errorProgress);
|
||||
throw error;
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
}, request);
|
||||
};
|
||||
|
||||
// GET - Get cached scan results for an image
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -130,6 +130,12 @@ function buildCreateConfig(inspectData: any, newImage: string): any {
|
||||
// Clear MacAddress for Docker API < 1.44 compatibility
|
||||
delete createConfig.MacAddress;
|
||||
|
||||
// Clear Entrypoint and Cmd so the new image's defaults are used.
|
||||
// This prevents carrying over a stale entrypoint from a previous runtime
|
||||
// (e.g. Bun's docker-entrypoint.sh → Node.js docker-entrypoint-node.sh).
|
||||
delete createConfig.Entrypoint;
|
||||
delete createConfig.Cmd;
|
||||
|
||||
// Clear Hostname so Docker assigns the new container's own ID
|
||||
// Otherwise the old container's hostname is inherited, breaking self-identification
|
||||
delete createConfig.Hostname;
|
||||
|
||||
@@ -432,6 +432,16 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
const loggableContainers = allContainers.filter((c: ContainerInfo) =>
|
||||
c.state === 'running' || c.state === 'exited'
|
||||
);
|
||||
|
||||
// Before updating containers, capture current running set for grouped mode change detection
|
||||
let prevRunningIds: string[] = [];
|
||||
if (layoutMode === 'grouped' && selectedContainerIds.size > 0) {
|
||||
prevRunningIds = Array.from(selectedContainerIds).filter(id => {
|
||||
const container = containers.find(c => c.id === id);
|
||||
return container?.state === 'running';
|
||||
});
|
||||
}
|
||||
|
||||
containers = loggableContainers;
|
||||
|
||||
// If selected container is no longer available, clear selection
|
||||
@@ -439,6 +449,23 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
selectedContainer = null;
|
||||
logs = '';
|
||||
}
|
||||
|
||||
// Grouped mode: restart stream if the running/stopped split changed
|
||||
if (layoutMode === 'grouped' && selectedContainerIds.size > 0 && streamingEnabled) {
|
||||
const newRunningIds = Array.from(selectedContainerIds).filter(id => {
|
||||
const container = loggableContainers.find((c: ContainerInfo) => c.id === id);
|
||||
return container?.state === 'running';
|
||||
});
|
||||
|
||||
const runningSetChanged =
|
||||
prevRunningIds.length !== newRunningIds.length ||
|
||||
!prevRunningIds.every(id => newRunningIds.includes(id));
|
||||
|
||||
if (runningSetChanged) {
|
||||
startGroupedStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
return loggableContainers;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch containers:', error);
|
||||
|
||||
Reference in New Issue
Block a user