diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 82ab463..8de5670 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -86,8 +86,8 @@ else # Check for UID conflicts - warn but don't delete other users SKIP_USER_CREATE=false - if getent passwd "$PUID" >/dev/null 2>&1; then - EXISTING=$(getent passwd "$PUID" | cut -d: -f1) + EXISTING=$(awk -F: -v uid="$PUID" '$3 == uid { print $1 }' /etc/passwd) + if [ -n "$EXISTING" ]; then if [ "$EXISTING" = "bun" ]; then echo "Note: UID $PUID is used by the 'bun' runtime user - reusing it for dockhand" echo "If upgrading from a previous version, you may need to fix data permissions:" @@ -101,9 +101,8 @@ else fi # Handle GID - reuse existing group or create new - if getent group "$PGID" >/dev/null 2>&1; then - TARGET_GROUP=$(getent group "$PGID" | cut -d: -f1) - else + 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 @@ -131,26 +130,37 @@ fi SOCKET_PATH="/var/run/docker.sock" if [ -S "$SOCKET_PATH" ]; then - # Socket exists - check if readable if [ "$RUN_USER" != "root" ]; then - if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then - SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown") - echo "WARNING: Docker socket at $SOCKET_PATH is not readable by $RUN_USER user" - echo "" - echo "To use local Docker, fix with one of these options:" - echo "" - echo " 1. Add container to docker group (GID: $SOCKET_GID):" - echo " docker run --group-add $SOCKET_GID ..." - echo "" - echo " 2. Use a socket proxy:" - echo " Configure a 'direct' environment pointing to tcp://socket-proxy:2375" - echo "" - echo " 3. Make socket world-readable (less secure):" - echo " chmod 666 /var/run/docker.sock" - echo "" - echo "Continuing startup - configure environments via the web UI..." - else - echo "Docker socket accessible at $SOCKET_PATH" + # Get socket GID + SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "") + + if [ -n "$SOCKET_GID" ]; then + # Check if user already has access + 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..." + + # Check if group with this GID exists (without getent, use /etc/group) + DOCKER_GROUP=$(awk -F: -v gid="$SOCKET_GID" '$3 == gid { print $1 }' /etc/group) + if [ -z "$DOCKER_GROUP" ]; then + # Create docker group with socket's GID + DOCKER_GROUP="docker" + addgroup -g "$SOCKET_GID" "$DOCKER_GROUP" 2>/dev/null || true + fi + + # Add user to docker group (try both busybox variants) + addgroup "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || \ + adduser "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || true + + # Verify access after adding to group + 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" diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 19efecb..e8a4c62 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,15 @@ [ + { + "version": "1.0.10", + "date": "2026-01-18", + "changes": [ + { "type": "fix", "text": "Fix docker socket access for custom PUID/PGID" }, + { "type": "fix", "text": "Fix stack creation with deploy failing when no env vars provided" }, + { "type": "fix", "text": "Fix env var validation flagging variables in commented lines as missing" }, + { "type": "fix", "text": "Show stop button for stacks in restart loop" } + ], + "imageTag": "fnsys/dockhand:v1.0.10" + }, { "version": "1.0.9", "date": "2026-01-17", diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 14c245d..dd79529 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -124,14 +124,14 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { return json({ success: true, started: false }); } + // ALWAYS save compose file first - deployStack expects it to exist + await saveStackComposeFile(name, compose, true, envIdNum, { + composePath: composePath || undefined, + envPath: envPath || undefined + }); + // Save environment variables BEFORE deploying so they're available during start if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) { - // First ensure the stack directory exists by saving compose file - await saveStackComposeFile(name, compose, true, envIdNum, { - composePath: composePath || undefined, - envPath: envPath || undefined - }); - // - rawEnvContent: non-secret vars with comments → .env file // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { diff --git a/src/routes/api/stacks/[name]/env/validate/+server.ts b/src/routes/api/stacks/[name]/env/validate/+server.ts index 85ad092..dbf3060 100644 --- a/src/routes/api/stacks/[name]/env/validate/+server.ts +++ b/src/routes/api/stacks/[name]/env/validate/+server.ts @@ -16,42 +16,53 @@ interface ValidationResult { /** * Extract environment variables from compose YAML content. * Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns. + * Ignores variables in commented lines (lines starting with #). * Returns { required: [...], optional: [...] } */ function extractComposeVars(yaml: string): { required: string[]; optional: string[] } { const required: string[] = []; const optional: string[] = []; - // Match ${VAR_NAME} (required) and ${VAR_NAME:-default} or ${VAR_NAME-default} (optional) - const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g; - let match; + // Process line by line to skip commented lines + const lines = yaml.split('\n'); + for (const line of lines) { + // Skip lines that are comments (start with # after optional whitespace) + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('#')) { + continue; + } - while ((match = regex.exec(yaml)) !== null) { - const varName = match[1]; - const hasDefault = match[2] !== undefined; + // Match ${VAR_NAME} (required) and ${VAR_NAME:-default} or ${VAR_NAME-default} (optional) + const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g; + let match; - if (hasDefault) { - if (!optional.includes(varName) && !required.includes(varName)) { - optional.push(varName); - } - } else { - // Move from optional to required if we find a non-default usage - const optIdx = optional.indexOf(varName); - if (optIdx !== -1) { - optional.splice(optIdx, 1); - } - if (!required.includes(varName)) { - required.push(varName); + while ((match = regex.exec(line)) !== null) { + const varName = match[1]; + const hasDefault = match[2] !== undefined; + + if (hasDefault) { + if (!optional.includes(varName) && !required.includes(varName)) { + optional.push(varName); + } + } else { + // Move from optional to required if we find a non-default usage + const optIdx = optional.indexOf(varName); + if (optIdx !== -1) { + optional.splice(optIdx, 1); + } + if (!required.includes(varName)) { + required.push(varName); + } } } - } - // Also match $VAR_NAME (simple variable substitution) - const simpleRegex = /\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g; - while ((match = simpleRegex.exec(yaml)) !== null) { - const varName = match[1]; - if (!required.includes(varName) && !optional.includes(varName)) { - required.push(varName); + // Also match $VAR_NAME (simple variable substitution) + const simpleRegex = /\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g; + while ((match = simpleRegex.exec(line)) !== null) { + const varName = match[1]; + if (!required.includes(varName) && !optional.includes(varName)) { + required.push(varName); + } } } diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 035dbb8..93b35a3 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -390,7 +390,7 @@ ); // Count by status for selected stacks - const selectedRunning = $derived(selectedInFilter.filter(s => s.status === 'running' || s.status === 'partial')); + const selectedRunning = $derived(selectedInFilter.filter(s => s.status === 'running' || s.status === 'partial' || s.status === 'restarting')); const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed')); function toggleSelectAll() { @@ -1413,7 +1413,7 @@
{#if stats} {stats.cpuPercent.toFixed(1)}% - {:else if stack.status === 'running' || stack.status === 'partial'} + {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} ... {:else} - @@ -1424,7 +1424,7 @@
{#if stats} {formatBytes(stats.memoryUsage)} - {:else if stack.status === 'running' || stack.status === 'partial'} + {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} ... {:else} - @@ -1437,7 +1437,7 @@ ↓{formatBytes(stats.networkRx, 0)} ↑{formatBytes(stats.networkTx, 0)} - {:else if stack.status === 'running' || stack.status === 'partial'} + {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} ... {:else} - @@ -1450,7 +1450,7 @@ r{formatBytes(stats.blockRead, 0)} w{formatBytes(stats.blockWrite, 0)} - {:else if stack.status === 'running' || stack.status === 'partial'} + {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} ... {:else} - @@ -1559,7 +1559,7 @@
- {:else if stack.status === 'running' || stack.status === 'partial'} + {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} {#if $canAccess('stacks', 'restart')}