From 7577b44efd4eb2fbd29e4e9558972f46c6f4df98 Mon Sep 17 00:00:00 2001 From: baxyz Date: Wed, 8 Apr 2026 20:53:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?chore(local-mounts):=20=F0=9F=94=A7=20updat?= =?UTF-8?q?e=20sync=20logic=20and=20improve=20documentation=20-=20move=20f?= =?UTF-8?q?ile=20sync=20to=20runtime=20via=20postStartCommand=20-=20add=20?= =?UTF-8?q?sync=20fallback=20script=20for=20shell=20startup=20-=20enhance?= =?UTF-8?q?=20README=20and=20test=20scripts=20for=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/local-mounts/README.md | 23 +- src/local-mounts/devcontainer-feature.json | 5 +- src/local-mounts/install.sh | 268 ++++++--------------- src/local-mounts/sync-files.sh | 121 ++++++++++ test/local-mounts/test.sh | 99 +++++--- 5 files changed, 279 insertions(+), 237 deletions(-) create mode 100644 src/local-mounts/sync-files.sh diff --git a/src/local-mounts/README.md b/src/local-mounts/README.md index 766f9c3..2f6c156 100644 --- a/src/local-mounts/README.md +++ b/src/local-mounts/README.md @@ -9,7 +9,7 @@ Mounts local Git, SSH, GPG, and npm configuration files into the devcontainer fo - **SSH agent forwarding**: Runtime detection with fallback chain (stable socket → VS Code native → legacy) - **GPG keys**: Sign commits with your GPG keys - **npm authentication**: Your `.npmrc` for private registry access -- **Post-start verification**: Validates mounted content and reports issues +- **Runtime sync**: Files synced at container start when bind mounts are available ## Usage @@ -132,12 +132,12 @@ The feature automatically configures these environment variables: ## How It Works -1. **Docker mounts** your local configuration files based on the `mounts` specification -2. **Verification script** (`install.sh`) runs inside the container to sync from staging mounts into `/home/` -3. **Fallback mechanism** creates placeholders after startup when possible -4. **Logging** shows what was mounted and what might need attention +1. **Build time** (`install.sh`): Creates directory structure (`~/.ssh/`, `~/.gnupg/`, etc.) and installs sync scripts +2. **Container start** (`postStartCommand`): Syncs files from staging bind mounts (`/tmp/local-mounts/`) to the user's home directory +3. **Shell startup** (`/etc/profile.d/`): Detects SSH agent socket with fallback chain + one-time sync fallback if `postStartCommand` hasn't run +4. **Logging**: Reports what was synced and warns about missing or empty files -> Important: bind mounts are resolved before the feature script runs. Missing host sources can block container startup. +> Important: bind mounts are resolved at **container start**, not during build. Missing host source paths can block container startup. ## Troubleshooting @@ -217,15 +217,18 @@ If `git fetch` or `ssh -T git@github.com` fails with `Permission denied (publick This feature includes a **robust fallback mechanism**: -1. ✅ If mount succeeded → Uses mounted files +1. ✅ If mount succeeded → `postStartCommand` syncs files to user's home 2. ✅ If mounted file content is empty → warns with troubleshooting hints -3. ✅ If startup succeeded but target path is missing → creates placeholder where possible -4. ⚠️ If host bind source is missing before startup → Docker can fail before script execution +3. ✅ If `postStartCommand` missed → first shell triggers sync via `profile.d` fallback +4. ✅ If startup succeeded but target path is missing → created at build time with correct permissions +5. ⚠️ If host bind source is missing before startup → Docker can fail before script execution -The `install.sh` script verifies all mounts and provides clear feedback on what's available. +The sync script (`/usr/local/share/local-mounts/sync-files.sh`) runs at container start and provides clear feedback on what was synced. ## Version History +- **v1.0.9**: **Architecture fix** — moved file sync from build-time (`install.sh`) to runtime (`postStartCommand`). Build-time sync was a no-op because bind mounts aren't available during `docker build`. Added `profile.d` fallback sync. +- **v1.0.8**: Fixed `ssh-add -l` exit code handling (accept exit 1 = no keys, reject only exit 2 = no agent). Fixed SSH workaround to copy all files (not just `id_*`). - **v1.0.7**: Replaced static `containerEnv` SSH_AUTH_SOCK with runtime detection via `/etc/profile.d/` — preserves VS Code native forwarding as fallback - **v1.0.6**: Added `username` option with mount staging (`/tmp/local-mounts`) and sync to `/home/` - **v1.0.5**: Removed fragile direct `$SSH_AUTH_SOCK` bind mount and switched to stable `~/.ssh/agent.sock` strategy diff --git a/src/local-mounts/devcontainer-feature.json b/src/local-mounts/devcontainer-feature.json index b9805b3..d1449df 100644 --- a/src/local-mounts/devcontainer-feature.json +++ b/src/local-mounts/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "local-mounts", - "version": "1.0.8", + "version": "1.0.9", "name": "Local Development Files Mount", - "description": "Mounts local Git, SSH, GPG, and npm configuration files into the devcontainer with support for custom usernames. Uses runtime SSH agent detection with fallback to VS Code native forwarding.", + "description": "Mounts local Git, SSH, GPG, and npm configuration files into the devcontainer with support for custom usernames. Syncs files at container start via postStartCommand. Uses runtime SSH agent detection with fallback to VS Code native forwarding.", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/features/local-mounts", "options": { "username": { @@ -36,6 +36,7 @@ "containerEnv": { "GPG_TTY": "/dev/pts/0" }, + "postStartCommand": "/usr/local/share/local-mounts/sync-files.sh", "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" ] diff --git a/src/local-mounts/install.sh b/src/local-mounts/install.sh index c011e04..048e4f7 100644 --- a/src/local-mounts/install.sh +++ b/src/local-mounts/install.sh @@ -1,16 +1,16 @@ #!/usr/bin/env bash -# Local Mounts DevContainer Feature -# Copyright (c) 2025 helpers4 -# Licensed under LGPL-3.0 - see LICENSE file for details +# This file is part of helpers4. +# Copyright (C) 2025 baxyz +# SPDX-License-Identifier: LGPL-3.0-or-later # -# Mounts local Git, SSH, GPG, and npm configuration files into the devcontainer -# for a configurable container user (default: node) -# This script runs INSIDE the container to verify and fix mounts +# Installs local-mounts devcontainer feature. +# This script runs at BUILD TIME — bind mounts are NOT available yet. +# File sync from mounts happens at container start via postStartCommand. set -e -USERNAME="${_BUILD_ARG_USERNAME:-node}" +USERNAME="${USERNAME:-"${_BUILD_ARG_USERNAME:-"node"}"}" SOURCE_HOME="/tmp/local-mounts" # Resolve target home robustly @@ -21,151 +21,57 @@ else fi echo "🔧 Setting up local-mounts devcontainer feature..." -echo "📁 Container user: ${USERNAME}" -echo "📁 Home directory: ${TARGET_HOME}" -echo "📁 Mount staging: ${SOURCE_HOME}" +echo " Container user: ${USERNAME}" +echo " Home directory: ${TARGET_HOME}" +echo " Mount staging: ${SOURCE_HOME}" echo "" # ============================================================================ -# CRITICAL: Verify mounts worked and create fallbacks if needed +# 1. Create directory structure (build time) # ============================================================================ -# Docker bind mounts may fail silently. This section ensures all -# configuration files exist in the target home for the configured user. +# Bind mounts are NOT available during docker build. +# Create the target directories so they exist when the container starts. -_sync_file_from_mount() { - local source_file="$1" - local target_file="$2" - local config_name="$3" +mkdir -p "${TARGET_HOME}/.ssh" "${TARGET_HOME}/.gnupg" 2>/dev/null || true +chmod 700 "${TARGET_HOME}/.ssh" "${TARGET_HOME}/.gnupg" 2>/dev/null || true +touch "${TARGET_HOME}/.gitconfig" "${TARGET_HOME}/.npmrc" 2>/dev/null || true - if [ -f "${source_file}" ]; then - cp -f "${source_file}" "${target_file}" 2>/dev/null || true - echo "✅ ${config_name} synchronized to ${target_file}" - elif [ ! -f "${target_file}" ]; then - touch "${target_file}" 2>/dev/null || true - echo "ℹ️ ${config_name} mount source not found, created empty ${target_file}" - fi -} - -_sync_dir_from_mount() { - local source_dir="$1" - local target_dir="$2" - local config_name="$3" - - if [ -d "${source_dir}" ]; then - mkdir -p "${target_dir}" 2>/dev/null || true - if ! cp -a "${source_dir}/." "${target_dir}/" 2>/dev/null; then - echo "⚠️ cp -a failed for ${config_name}, falling back to file-by-file copy" - find "${source_dir}" -maxdepth 1 -type f -exec cp -f {} "${target_dir}/" \; - fi - echo "✅ ${config_name} synchronized to ${target_dir}" - elif [ ! -d "${target_dir}" ]; then - mkdir -p "${target_dir}" 2>/dev/null || true - echo "ℹ️ ${config_name} mount source not found, created ${target_dir}" - fi -} - -_ensure_config_files() { - local target_dir="$1" - - # Ensure directories exist - mkdir -p "${target_dir}/.ssh" 2>/dev/null || true - mkdir -p "${target_dir}/.gnupg" 2>/dev/null || true - chmod 700 "${target_dir}/.ssh" 2>/dev/null || true - chmod 700 "${target_dir}/.gnupg" 2>/dev/null || true - - # Ensure regular files exist (empty if not mounted) - touch "${target_dir}/.gitconfig" 2>/dev/null || true - touch "${target_dir}/.npmrc" 2>/dev/null || true -} - -mkdir -p "${TARGET_HOME}" 2>/dev/null || true - -_sync_file_from_mount "${SOURCE_HOME}/.gitconfig" "${TARGET_HOME}/.gitconfig" ".gitconfig" -_sync_dir_from_mount "${SOURCE_HOME}/.ssh" "${TARGET_HOME}/.ssh" ".ssh" -_sync_dir_from_mount "${SOURCE_HOME}/.gnupg" "${TARGET_HOME}/.gnupg" ".gnupg" -_sync_file_from_mount "${SOURCE_HOME}/.npmrc" "${TARGET_HOME}/.npmrc" ".npmrc" - -# ============================================================================ -# WORKAROUND: cp -a may silently fail to copy files within mounted dirs -# Explicitly ensure SSH keys/config and GPG private keys are synced -# ============================================================================ - -# Ensure ALL SSH files are synced (not just id_* keys) -if [ -d "${SOURCE_HOME}/.ssh" ]; then - find "${SOURCE_HOME}/.ssh" -maxdepth 1 -type f -exec cp -f {} "${TARGET_HOME}/.ssh/" \; - # Fix permissions - chmod 700 "${TARGET_HOME}/.ssh" - find "${TARGET_HOME}/.ssh" -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; - find "${TARGET_HOME}/.ssh" -name "*.pub" -exec chmod 644 {} \; - [ -f "${TARGET_HOME}/.ssh/config" ] && chmod 600 "${TARGET_HOME}/.ssh/config" - # Verify sync completeness - SOURCE_COUNT=$(find "${SOURCE_HOME}/.ssh" -maxdepth 1 -type f 2>/dev/null | wc -l) - TARGET_COUNT=$(find "${TARGET_HOME}/.ssh" -maxdepth 1 -type f 2>/dev/null | wc -l) - if [ "$SOURCE_COUNT" -gt 0 ] && [ "$TARGET_COUNT" -lt "$SOURCE_COUNT" ]; then - echo "⚠️ SSH sync incomplete: ${TARGET_COUNT}/${SOURCE_COUNT} files copied" - else - echo "✅ SSH files synchronized (${TARGET_COUNT} files)" - fi -fi - -# Ensure GPG private keys are synced -if [ -d "${SOURCE_HOME}/.gnupg/private-keys-v1.d" ]; then - mkdir -p "${TARGET_HOME}/.gnupg/private-keys-v1.d" 2>/dev/null || true - cp -f "${SOURCE_HOME}/.gnupg/private-keys-v1.d/"*.key \ - "${TARGET_HOME}/.gnupg/private-keys-v1.d/" 2>/dev/null || true - chmod 700 "${TARGET_HOME}/.gnupg/private-keys-v1.d" 2>/dev/null || true - chmod 600 "${TARGET_HOME}/.gnupg/private-keys-v1.d/"*.key 2>/dev/null || true - echo "✅ GPG private keys synchronized" -fi - -_ensure_config_files "${TARGET_HOME}" - -# Best effort ownership fix for target user +# Fix ownership for target user if getent passwd "${USERNAME}" >/dev/null 2>&1; then - chown -R "${USERNAME}:${USERNAME}" "${TARGET_HOME}/.ssh" "${TARGET_HOME}/.gnupg" "${TARGET_HOME}/.gitconfig" "${TARGET_HOME}/.npmrc" 2>/dev/null || true + chown -R "${USERNAME}:${USERNAME}" \ + "${TARGET_HOME}/.ssh" \ + "${TARGET_HOME}/.gnupg" \ + "${TARGET_HOME}/.gitconfig" \ + "${TARGET_HOME}/.npmrc" 2>/dev/null || true fi +echo "✅ Directory structure created" + # ============================================================================ -# VERIFY: Check what was actually mounted vs what's empty +# 2. Install runtime sync script # ============================================================================ +# This script runs at container start (via postStartCommand) when bind mounts +# are available. It copies files from /tmp/local-mounts/ to the user's home. -_check_mount_status() { - local target_dir="$1" - local config_file="$2" - local config_name="$3" - - if [ ! -f "${target_dir}/${config_file}" ]; then - echo "⚠️ ${config_name} not found - creating empty" - touch "${target_dir}/${config_file}" 2>/dev/null || true - return 1 - fi - - # Check if file is empty (likely mount failed) - if [ ! -s "${target_dir}/${config_file}" ]; then - echo "⚠️ ${config_name} is empty (mount may have failed)" - return 1 - fi - - echo "✅ ${config_name} is present and has content" - return 0 -} +mkdir -p /usr/local/share/local-mounts -echo "📋 Verifying configuration file mounts:" -echo "" +# Store build-time configuration for runtime use +cat > /usr/local/share/local-mounts/config << CONF_EOF +LOCAL_MOUNTS_USERNAME="${USERNAME}" +LOCAL_MOUNTS_SOURCE="${SOURCE_HOME}" +LOCAL_MOUNTS_TARGET="${TARGET_HOME}" +CONF_EOF -_check_mount_status "${TARGET_HOME}" ".npmrc" ".npmrc" || true -_check_mount_status "${TARGET_HOME}" ".gitconfig" ".gitconfig" || true -[ -d "${TARGET_HOME}/.ssh" ] && echo "✅ .ssh directory exists" || echo "⚠️ .ssh directory not found" -[ -d "${TARGET_HOME}/.gnupg" ] && echo "✅ .gnupg directory exists" || echo "⚠️ .gnupg directory not found" +cp "$(dirname "$0")/sync-files.sh" /usr/local/share/local-mounts/sync-files.sh +chmod +x /usr/local/share/local-mounts/sync-files.sh -echo "" +echo "✅ Runtime sync script installed (/usr/local/share/local-mounts/sync-files.sh)" # ============================================================================ -# SSH_AUTH_SOCK: Runtime detection with fallback chain +# 3. Install SSH_AUTH_SOCK runtime detection (profile.d) # ============================================================================ -# containerEnv is static and overrides VS Code's native SSH forwarding. -# Instead, detect at shell startup time with proper fallback. -# Priority: 1) Stable host socket 2) VS Code native forwarding 3) Legacy /ssh-agent +# Detects the best SSH agent socket at shell startup. +# Priority: 1) Stable host socket 2) VS Code native 3) Legacy /ssh-agent cat > /etc/profile.d/local-mounts-ssh.sh << 'PROFILE_EOF' # local-mounts: SSH agent socket detection (runtime) @@ -195,79 +101,45 @@ unset -f _ssh_agent_responds PROFILE_EOF chmod +x /etc/profile.d/local-mounts-ssh.sh -echo "✅ SSH agent detection installed (/etc/profile.d/local-mounts-ssh.sh)" -# Test SSH agent forwarding at install time (informational only) -STABLE_SSH_AGENT_SOCKET="${SOURCE_HOME}/.ssh/agent.sock" +echo "✅ SSH agent detection installed (/etc/profile.d/local-mounts-ssh.sh)" -if [ -S "$STABLE_SSH_AGENT_SOCKET" ]; then - echo "✅ SSH stable socket found at ${STABLE_SSH_AGENT_SOCKET}" - if command -v ssh-add >/dev/null 2>&1; then - SSH_AUTH_SOCK="$STABLE_SSH_AGENT_SOCKET" ssh-add -l >/dev/null 2>&1 \ - && echo " - SSH keys accessible via stable socket" \ - || echo " - No SSH keys loaded in agent" - fi -elif [ -n "$SSH_AUTH_SOCK" ] && [ -S "$SSH_AUTH_SOCK" ]; then - echo "✅ SSH agent forwarding available (VS Code native: $SSH_AUTH_SOCK)" -elif [ -S "/ssh-agent" ]; then - echo "✅ SSH agent forwarding available (legacy socket: /ssh-agent)" -elif [ -d "${TARGET_HOME}/.ssh" ]; then - echo "ℹ️ No SSH agent socket found — VS Code native forwarding will be used at runtime" - if [ -f "${TARGET_HOME}/.ssh/id_rsa" ] || [ -f "${TARGET_HOME}/.ssh/id_ed25519" ]; then - echo " - SSH keys detected in ${TARGET_HOME}/.ssh" - fi -else - echo "ℹ️ No SSH configuration detected (optional)" +# ============================================================================ +# 4. Install one-time sync fallback (profile.d) +# ============================================================================ +# Safety net: if postStartCommand didn't run (or hasn't finished yet), +# the first interactive shell triggers the sync. + +cat > /etc/profile.d/local-mounts-sync.sh << 'PROFILE_EOF' +# local-mounts: one-time file sync fallback +# Runs sync on first shell if postStartCommand hasn't completed yet +_LOCAL_MOUNTS_MARKER="/tmp/.local-mounts-synced" +if [ ! -f "$_LOCAL_MOUNTS_MARKER" ] && [ -d "/tmp/local-mounts" ]; then + /usr/local/share/local-mounts/sync-files.sh 2>/dev/null || true fi +unset _LOCAL_MOUNTS_MARKER +PROFILE_EOF -# Test GPG setup -if command -v gpg >/dev/null 2>&1; then - if [ -d "${TARGET_HOME}/.gnupg" ]; then - if gpg --list-secret-keys >/dev/null 2>&1; then - GPG_KEYS=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null | grep -c "^sec" || echo "0") - echo "✅ GPG configured (${GPG_KEYS} secret key(s) found)" - else - echo "ℹ️ GPG mounted but no secret keys" - fi - else - echo "ℹ️ GPG directory not available (optional for commit signing)" - fi -else - echo "ℹ️ GPG not installed" -fi +chmod +x /etc/profile.d/local-mounts-sync.sh -# Special check for .npmrc - this is the critical one -echo "" -if [ -f "${TARGET_HOME}/.npmrc" ] && [ -s "${TARGET_HOME}/.npmrc" ]; then - echo "✅ npm configuration (.npmrc) mounted with content" - if grep -qE "(authToken|_auth|//.*:_)" "${TARGET_HOME}/.npmrc" 2>/dev/null; then - echo " - Authentication tokens are configured" - else - echo " - No tokens configured (public registries only)" - fi -elif [ -f "${TARGET_HOME}/.npmrc" ]; then - echo "⚠️ npm configuration (.npmrc) exists but is empty" - echo " - This might indicate the mount failed or source file was empty" - echo " - Configure tokens in ~/.npmrc on your host machine" -else - echo "⚠️ npm configuration (.npmrc) not found" - echo " ➜ Edit ~/.npmrc on your host machine to add authentication tokens" -fi +echo "✅ Sync fallback installed (/etc/profile.d/local-mounts-sync.sh)" + +# ============================================================================ +# Summary +# ============================================================================ echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "🎉 Local development files mount verification complete!" +echo "🎉 local-mounts feature installed!" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -echo "📋 Configuration Summary:" +echo "📋 Architecture:" +echo " Build time → Directory structure + scripts installed" +echo " Start time → postStartCommand syncs files from bind mounts" +echo " Shell start → SSH_AUTH_SOCK detection + sync fallback" +echo "" +echo "📁 Targets:" echo " Git config → ${TARGET_HOME}/.gitconfig" -echo " SSH keys → ${TARGET_HOME}/.ssh" -echo " GPG keys → ${TARGET_HOME}/.gnupg" +echo " SSH keys → ${TARGET_HOME}/.ssh/" +echo " GPG keys → ${TARGET_HOME}/.gnupg/" echo " npm tokens → ${TARGET_HOME}/.npmrc" -echo " SSH agent → ${STABLE_SSH_AGENT_SOCKET}" -echo "" -echo "🔧 To troubleshoot mount issues:" -echo " 1. Check host files exist: ls -la ~/{.npmrc,.gitconfig,.ssh,.gnupg}" -echo " 2. Verify mount points: ls -la ${TARGET_HOME}/" -echo " 3. Compare file contents: diff ~/.npmrc ${TARGET_HOME}/.npmrc" -echo " - ~/.npmrc → ${TARGET_HOME}/.npmrc" diff --git a/src/local-mounts/sync-files.sh b/src/local-mounts/sync-files.sh new file mode 100644 index 0000000..9b56b35 --- /dev/null +++ b/src/local-mounts/sync-files.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# This file is part of helpers4. +# Copyright (C) 2025 baxyz +# SPDX-License-Identifier: LGPL-3.0-or-later +# +# Runtime file sync for local-mounts feature. +# Runs at container start (postStartCommand) when bind mounts are available. +# Also called by profile.d fallback on first shell if postStartCommand missed. + +# No set -e: sync as much as possible even if one part fails. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +. "${SCRIPT_DIR}/config" + +USERNAME="${LOCAL_MOUNTS_USERNAME}" +SOURCE_HOME="${LOCAL_MOUNTS_SOURCE}" +TARGET_HOME="${LOCAL_MOUNTS_TARGET}" + +# Check staging directory exists (bind mounts active?) +if [ ! -d "${SOURCE_HOME}" ]; then + echo "ℹ️ local-mounts: staging directory ${SOURCE_HOME} not found, skipping sync" + exit 0 +fi + +echo "🔧 local-mounts: syncing files from ${SOURCE_HOME} to ${TARGET_HOME}..." + +# ── Sync .gitconfig ────────────────────────────────────────────────────────── + +if [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; then + cp -f "${SOURCE_HOME}/.gitconfig" "${TARGET_HOME}/.gitconfig" + echo " ✅ .gitconfig" +elif [ -f "${SOURCE_HOME}/.gitconfig" ]; then + echo " ⚠️ .gitconfig exists but is empty" +else + echo " ℹ️ .gitconfig not found in staging" +fi + +# ── Sync .npmrc ────────────────────────────────────────────────────────────── + +if [ -f "${SOURCE_HOME}/.npmrc" ] && [ -s "${SOURCE_HOME}/.npmrc" ]; then + cp -f "${SOURCE_HOME}/.npmrc" "${TARGET_HOME}/.npmrc" + echo " ✅ .npmrc" +elif [ -f "${SOURCE_HOME}/.npmrc" ]; then + echo " ⚠️ .npmrc exists but is empty" +else + echo " ℹ️ .npmrc not found in staging" +fi + +# ── Sync .ssh ──────────────────────────────────────────────────────────────── + +if [ -d "${SOURCE_HOME}/.ssh" ]; then + mkdir -p "${TARGET_HOME}/.ssh" + + # Copy all regular files (keys, config, known_hosts, etc.) + find "${SOURCE_HOME}/.ssh" -maxdepth 1 -type f -exec cp -f {} "${TARGET_HOME}/.ssh/" \; + + # Fix permissions + chmod 700 "${TARGET_HOME}/.ssh" + find "${TARGET_HOME}/.ssh" -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; + find "${TARGET_HOME}/.ssh" -name "*.pub" -exec chmod 644 {} \; + [ -f "${TARGET_HOME}/.ssh/config" ] && chmod 600 "${TARGET_HOME}/.ssh/config" + [ -f "${TARGET_HOME}/.ssh/known_hosts" ] && chmod 644 "${TARGET_HOME}/.ssh/known_hosts" + + # Report + FILE_COUNT=$(find "${TARGET_HOME}/.ssh" -maxdepth 1 -type f | wc -l) + echo " ✅ .ssh (${FILE_COUNT} files)" +else + echo " ℹ️ .ssh not found in staging" +fi + +# ── Sync .gnupg ────────────────────────────────────────────────────────────── + +if [ -d "${SOURCE_HOME}/.gnupg" ]; then + mkdir -p "${TARGET_HOME}/.gnupg" + + # Copy top-level files (pubring, trustdb, gpg.conf, etc.) + find "${SOURCE_HOME}/.gnupg" -maxdepth 1 -type f -exec cp -f {} "${TARGET_HOME}/.gnupg/" \; + + # Copy private keys subdirectory + if [ -d "${SOURCE_HOME}/.gnupg/private-keys-v1.d" ]; then + mkdir -p "${TARGET_HOME}/.gnupg/private-keys-v1.d" + find "${SOURCE_HOME}/.gnupg/private-keys-v1.d" -maxdepth 1 -type f \ + -exec cp -f {} "${TARGET_HOME}/.gnupg/private-keys-v1.d/" \; + chmod 700 "${TARGET_HOME}/.gnupg/private-keys-v1.d" + find "${TARGET_HOME}/.gnupg/private-keys-v1.d" -type f -exec chmod 600 {} \; + fi + + # Copy openpgp-revocs subdirectory + if [ -d "${SOURCE_HOME}/.gnupg/openpgp-revocs.d" ]; then + mkdir -p "${TARGET_HOME}/.gnupg/openpgp-revocs.d" + find "${SOURCE_HOME}/.gnupg/openpgp-revocs.d" -maxdepth 1 -type f \ + -exec cp -f {} "${TARGET_HOME}/.gnupg/openpgp-revocs.d/" \; + chmod 700 "${TARGET_HOME}/.gnupg/openpgp-revocs.d" + find "${TARGET_HOME}/.gnupg/openpgp-revocs.d" -type f -exec chmod 600 {} \; + fi + + # Fix top-level permissions + chmod 700 "${TARGET_HOME}/.gnupg" + find "${TARGET_HOME}/.gnupg" -maxdepth 1 -type f -exec chmod 600 {} \; + + echo " ✅ .gnupg" +else + echo " ℹ️ .gnupg not found in staging" +fi + +# ── Fix ownership ──────────────────────────────────────────────────────────── + +if [ "$(id -u)" -eq 0 ] && getent passwd "${USERNAME}" >/dev/null 2>&1; then + chown -R "${USERNAME}:${USERNAME}" \ + "${TARGET_HOME}/.ssh" \ + "${TARGET_HOME}/.gnupg" \ + "${TARGET_HOME}/.gitconfig" \ + "${TARGET_HOME}/.npmrc" 2>/dev/null || true +fi + +# Signal sync completed (used by profile.d fallback) +touch /tmp/.local-mounts-synced 2>/dev/null || true + +echo "✅ local-mounts: sync complete" diff --git a/test/local-mounts/test.sh b/test/local-mounts/test.sh index 3d4eec1..d0fb2c5 100644 --- a/test/local-mounts/test.sh +++ b/test/local-mounts/test.sh @@ -23,34 +23,90 @@ else exit 1 fi -# Test 3: Check mount points exist (these may or may not have content depending on host) +# Test 3: Check directory structure was created at build time TARGET_HOME="${HOME:-/home/node}" -echo "📁 Checking expected mount points at ${TARGET_HOME}..." +echo "📁 Checking directory structure at ${TARGET_HOME}..." -# Note: These tests check structure, not content (content depends on host configuration) -MOUNT_POINTS=(".gitconfig" ".ssh" ".gnupg" ".npmrc") -FOUND_COUNT=0 +DIRS=(".ssh" ".gnupg") +for dir in "${DIRS[@]}"; do + if [ -d "${TARGET_HOME}/${dir}" ]; then + echo "✅ PASS: ${dir} directory exists" + PERMS=$(stat -c "%a" "${TARGET_HOME}/${dir}" 2>/dev/null || stat -f "%OLp" "${TARGET_HOME}/${dir}" 2>/dev/null) + if [ "$PERMS" = "700" ]; then + echo " ✅ Permissions correct (700)" + else + echo " ⚠️ Permissions: ${PERMS} (expected 700)" + fi + else + echo "❌ FAIL: ${dir} directory not found" + exit 1 + fi +done -for mount in "${MOUNT_POINTS[@]}"; do - if [ -e "${TARGET_HOME}/${mount}" ]; then - echo "✅ PASS: ${mount} exists" - FOUND_COUNT=$((FOUND_COUNT + 1)) +FILES=(".gitconfig" ".npmrc") +for file in "${FILES[@]}"; do + if [ -f "${TARGET_HOME}/${file}" ]; then + echo "✅ PASS: ${file} exists" else - echo "ℹ️ INFO: ${mount} not found (may not exist on host)" + echo "❌ FAIL: ${file} not found" + exit 1 fi done -# Test 4: SSH agent runtime detection script exists -PROFILE_SCRIPT="/etc/profile.d/local-mounts-ssh.sh" -if [ -f "$PROFILE_SCRIPT" ]; then - echo "✅ PASS: SSH agent detection script installed at ${PROFILE_SCRIPT}" +# Test 4: Sync script is installed and executable +SYNC_SCRIPT="/usr/local/share/local-mounts/sync-files.sh" +if [ -x "$SYNC_SCRIPT" ]; then + echo "✅ PASS: Sync script installed at ${SYNC_SCRIPT}" +else + echo "❌ FAIL: Sync script not found or not executable at ${SYNC_SCRIPT}" + exit 1 +fi + +# Test 5: Config file exists with valid content +CONFIG_FILE="/usr/local/share/local-mounts/config" +if [ -f "$CONFIG_FILE" ]; then + echo "✅ PASS: Config file exists at ${CONFIG_FILE}" + if grep -q "LOCAL_MOUNTS_USERNAME=" "$CONFIG_FILE" && \ + grep -q "LOCAL_MOUNTS_SOURCE=" "$CONFIG_FILE" && \ + grep -q "LOCAL_MOUNTS_TARGET=" "$CONFIG_FILE"; then + echo " ✅ Config contains expected variables" + else + echo " ❌ FAIL: Config missing expected variables" + exit 1 + fi +else + echo "❌ FAIL: Config file not found at ${CONFIG_FILE}" + exit 1 +fi + +# Test 6: SSH agent runtime detection script exists +PROFILE_SSH="/etc/profile.d/local-mounts-ssh.sh" +if [ -f "$PROFILE_SSH" ]; then + echo "✅ PASS: SSH agent detection script installed at ${PROFILE_SSH}" +else + echo "❌ FAIL: SSH agent detection script not found at ${PROFILE_SSH}" + exit 1 +fi + +# Test 7: Sync fallback script exists +PROFILE_SYNC="/etc/profile.d/local-mounts-sync.sh" +if [ -f "$PROFILE_SYNC" ]; then + echo "✅ PASS: Sync fallback script installed at ${PROFILE_SYNC}" else - echo "❌ FAIL: SSH agent detection script not found at ${PROFILE_SCRIPT}" + echo "❌ FAIL: Sync fallback script not found at ${PROFILE_SYNC}" exit 1 fi -# Test 5: SSH agent socket strategy (informational) +# Test 8: Sync script runs without error (even with no mount data) +echo "🔧 Running sync script (no mount data expected in test)..." +if "${SYNC_SCRIPT}" 2>&1; then + echo "✅ PASS: Sync script runs without error" +else + echo "⚠️ WARN: Sync script exited with non-zero (may be expected in test environment)" +fi + +# Test 9: SSH agent socket strategy (informational) if [ -n "$SSH_AUTH_SOCK" ]; then echo "ℹ️ INFO: SSH_AUTH_SOCK is set to: $SSH_AUTH_SOCK" if [ -S "$SSH_AUTH_SOCK" ]; then @@ -62,16 +118,5 @@ else echo "ℹ️ INFO: SSH_AUTH_SOCK not set (will be resolved at shell startup via profile.d)" fi -STABLE_SOCKET="/tmp/local-mounts/.ssh/agent.sock" -if [ -S "$STABLE_SOCKET" ]; then - echo "✅ PASS: Stable SSH socket found at $STABLE_SOCKET" -else - echo "ℹ️ INFO: Stable SSH socket not found at $STABLE_SOCKET (agent may be disabled)" -fi - echo "" echo "🎉 local-mounts feature test complete!" -echo "" -echo "Test summary:" -echo "- Mount points found: ${FOUND_COUNT}/${#MOUNT_POINTS[@]}" -echo "- Note: Missing mount points may be normal if files don't exist on host" From bd24ed42fd3fcdf7a41aa334dbc61bc61e7b0dca Mon Sep 17 00:00:00 2001 From: baxyz Date: Wed, 8 Apr 2026 21:23:38 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(local-mounts):=20=F0=9F=90=9B=20improve?= =?UTF-8?q?=20username=20resolution=20and=20config=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/local-mounts/install.sh | 2 +- src/local-mounts/sync-files.sh | 62 ++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/local-mounts/install.sh b/src/local-mounts/install.sh index 048e4f7..88c5d8a 100644 --- a/src/local-mounts/install.sh +++ b/src/local-mounts/install.sh @@ -10,7 +10,7 @@ set -e -USERNAME="${USERNAME:-"${_BUILD_ARG_USERNAME:-"node"}"}" +USERNAME="${_BUILD_ARG_USERNAME:-"${USERNAME:-"node"}"}" SOURCE_HOME="/tmp/local-mounts" # Resolve target home robustly diff --git a/src/local-mounts/sync-files.sh b/src/local-mounts/sync-files.sh index 9b56b35..aeb9e50 100644 --- a/src/local-mounts/sync-files.sh +++ b/src/local-mounts/sync-files.sh @@ -11,13 +11,25 @@ # No set -e: sync as much as possible even if one part fails. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config" + +if [ ! -r "${CONFIG_FILE}" ]; then + echo "❌ local-mounts: config file ${CONFIG_FILE} not found or not readable, aborting sync" + exit 1 +fi + # shellcheck source=/dev/null -. "${SCRIPT_DIR}/config" +. "${CONFIG_FILE}" USERNAME="${LOCAL_MOUNTS_USERNAME}" SOURCE_HOME="${LOCAL_MOUNTS_SOURCE}" TARGET_HOME="${LOCAL_MOUNTS_TARGET}" +if [ -z "${USERNAME}" ] || [ -z "${SOURCE_HOME}" ] || [ -z "${TARGET_HOME}" ]; then + echo "❌ local-mounts: config is missing required values, aborting sync" + exit 1 +fi + # Check staging directory exists (bind mounts active?) if [ ! -d "${SOURCE_HOME}" ]; then echo "ℹ️ local-mounts: staging directory ${SOURCE_HOME} not found, skipping sync" @@ -28,8 +40,11 @@ echo "🔧 local-mounts: syncing files from ${SOURCE_HOME} to ${TARGET_HOME}..." # ── Sync .gitconfig ────────────────────────────────────────────────────────── -if [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; then +if [ -L "${SOURCE_HOME}/.gitconfig" ]; then + echo " ⚠️ .gitconfig is a symlink, skipping for security" +elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; then cp -f "${SOURCE_HOME}/.gitconfig" "${TARGET_HOME}/.gitconfig" + chmod 600 "${TARGET_HOME}/.gitconfig" echo " ✅ .gitconfig" elif [ -f "${SOURCE_HOME}/.gitconfig" ]; then echo " ⚠️ .gitconfig exists but is empty" @@ -39,8 +54,11 @@ fi # ── Sync .npmrc ────────────────────────────────────────────────────────────── -if [ -f "${SOURCE_HOME}/.npmrc" ] && [ -s "${SOURCE_HOME}/.npmrc" ]; then +if [ -L "${SOURCE_HOME}/.npmrc" ]; then + echo " ⚠️ .npmrc is a symlink, skipping for security" +elif [ -f "${SOURCE_HOME}/.npmrc" ] && [ -s "${SOURCE_HOME}/.npmrc" ]; then cp -f "${SOURCE_HOME}/.npmrc" "${TARGET_HOME}/.npmrc" + chmod 600 "${TARGET_HOME}/.npmrc" echo " ✅ .npmrc" elif [ -f "${SOURCE_HOME}/.npmrc" ]; then echo " ⚠️ .npmrc exists but is empty" @@ -53,8 +71,8 @@ fi if [ -d "${SOURCE_HOME}/.ssh" ]; then mkdir -p "${TARGET_HOME}/.ssh" - # Copy all regular files (keys, config, known_hosts, etc.) - find "${SOURCE_HOME}/.ssh" -maxdepth 1 -type f -exec cp -f {} "${TARGET_HOME}/.ssh/" \; + # Copy all regular files, skip symlinks for security + find "${SOURCE_HOME}/.ssh" -maxdepth 1 -type f ! -type l -exec cp -f {} "${TARGET_HOME}/.ssh/" \; # Fix permissions chmod 700 "${TARGET_HOME}/.ssh" @@ -75,30 +93,16 @@ fi if [ -d "${SOURCE_HOME}/.gnupg" ]; then mkdir -p "${TARGET_HOME}/.gnupg" - # Copy top-level files (pubring, trustdb, gpg.conf, etc.) - find "${SOURCE_HOME}/.gnupg" -maxdepth 1 -type f -exec cp -f {} "${TARGET_HOME}/.gnupg/" \; - - # Copy private keys subdirectory - if [ -d "${SOURCE_HOME}/.gnupg/private-keys-v1.d" ]; then - mkdir -p "${TARGET_HOME}/.gnupg/private-keys-v1.d" - find "${SOURCE_HOME}/.gnupg/private-keys-v1.d" -maxdepth 1 -type f \ - -exec cp -f {} "${TARGET_HOME}/.gnupg/private-keys-v1.d/" \; - chmod 700 "${TARGET_HOME}/.gnupg/private-keys-v1.d" - find "${TARGET_HOME}/.gnupg/private-keys-v1.d" -type f -exec chmod 600 {} \; - fi - - # Copy openpgp-revocs subdirectory - if [ -d "${SOURCE_HOME}/.gnupg/openpgp-revocs.d" ]; then - mkdir -p "${TARGET_HOME}/.gnupg/openpgp-revocs.d" - find "${SOURCE_HOME}/.gnupg/openpgp-revocs.d" -maxdepth 1 -type f \ - -exec cp -f {} "${TARGET_HOME}/.gnupg/openpgp-revocs.d/" \; - chmod 700 "${TARGET_HOME}/.gnupg/openpgp-revocs.d" - find "${TARGET_HOME}/.gnupg/openpgp-revocs.d" -type f -exec chmod 600 {} \; - fi - - # Fix top-level permissions - chmod 700 "${TARGET_HOME}/.gnupg" - find "${TARGET_HOME}/.gnupg" -maxdepth 1 -type f -exec chmod 600 {} \; + # Recursively mirror directories and regular files, skip sockets/symlinks + ( + cd "${SOURCE_HOME}/.gnupg" || exit 1 + find . -type d -exec mkdir -p "${TARGET_HOME}/.gnupg/{}" \; + find . -type f ! -type l -exec cp -f "{}" "${TARGET_HOME}/.gnupg/{}" \; + ) + + # Fix permissions recursively + find "${TARGET_HOME}/.gnupg" -type d -exec chmod 700 {} \; + find "${TARGET_HOME}/.gnupg" -type f -exec chmod 600 {} \; echo " ✅ .gnupg" else