diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a947e00..af6ea999 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,10 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' PLUGIN_NAME: compose.manager + HOST_WORKSPACE_ROOT: ${{ github.workspace }} + HOST_SOURCE_PATH: ${{ github.workspace }}/source + HOST_ARCHIVE_PATH: ${{ github.workspace }}/archive + HOST_CACHE_PATH: ${{ github.workspace }}/archive/.build-cache jobs: build: diff --git a/.gitignore b/.gitignore index 8a1b5ca9..8963672b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ Thumbs.db # Runtime config files (created at runtime, not source) autoupdate.json +.env diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 3e840cf3..377c8aaf --- a/build.sh +++ b/build.sh @@ -12,6 +12,26 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OUTPUT_PATH="$SCRIPT_DIR/archive" PLG_FILE="$SCRIPT_DIR/compose.manager.plg" VERSIONS_FILE="$SCRIPT_DIR/versions.env" +BUILD_ENV_FILE="${BUILD_ENV_FILE:-$SCRIPT_DIR/.env}" + +load_env_file() { + local env_file="$1" + if [[ -n "$env_file" && -f "$env_file" ]]; then + # shellcheck disable=SC1090 + set -a + source "$env_file" + set +a + fi +} + +load_env_file "$VERSIONS_FILE" +load_env_file "$BUILD_ENV_FILE" + +# Environment override knobs for nested Docker / Unraid host setups. +HOST_WORKSPACE_ROOT="${HOST_WORKSPACE_ROOT:-}" +HOST_SOURCE_PATH="${HOST_SOURCE_PATH:-}" +HOST_ARCHIVE_PATH_OVERRIDE="${HOST_ARCHIVE_PATH:-}" +HOST_CACHE_PATH_OVERRIDE="${HOST_CACHE_PATH:-}" # Argument parsing while [[ $# -gt 0 ]]; do @@ -44,7 +64,7 @@ if [[ -f "$VERSIONS_FILE" ]]; then [[ "$line" =~ ^RESVG_SHA256=(.+)$ ]] && [[ -z "$RESVG_SHA256" ]] && RESVG_SHA256="${BASH_REMATCH[1]}" done < "$VERSIONS_FILE" fi -: "${COMPOSE_VERSION:=5.1.2}" +: "${COMPOSE_VERSION:=5.5.0}" : "${RESVG_VERSION:=0.48.1}" # Generate dev version with timestamp if requested @@ -114,7 +134,13 @@ mkdir -p "$ARCHIVE_PATH" # Host path for docker socket operations should be the actual unRAID path. HOST_ARCHIVE_PATH="$ARCHIVE_PATH" -if [[ "$in_container" == true && -d "/code" ]]; then +if [[ -n "$HOST_ARCHIVE_PATH_OVERRIDE" ]]; then + HOST_ARCHIVE_PATH="$HOST_ARCHIVE_PATH_OVERRIDE" +elif [[ -n "$HOST_WORKSPACE_ROOT" ]]; then + HOST_ARCHIVE_PATH="${HOST_WORKSPACE_ROOT%/}/compose_plugin/archive" +elif [[ "$in_container" == true && -d "/mnt/user/code" ]]; then + HOST_ARCHIVE_PATH="/mnt/user/code/compose_plugin/archive" +elif [[ "$in_container" == true && -d "/code" ]]; then # Map /code inside container to host path for Docker bind mounts (normally /mnt/user/code). read -r host_root host_source < <(awk '$5=="/code" {for(i=1;i<=NF;i++){if($i=="-"){print $4, $(i+2); exit}}}' /proc/self/mountinfo 2>/dev/null || true) if [[ -n "$host_root" && -n "$host_source" ]]; then @@ -158,8 +184,18 @@ mkdir -p "$HOST_ARCHIVE_PATH" 2>/dev/null || true CACHE_PATH="$ARCHIVE_PATH/.build-cache" HOST_CACHE_PATH="$HOST_ARCHIVE_PATH/.build-cache" +if [[ -n "$HOST_CACHE_PATH_OVERRIDE" ]]; then + HOST_CACHE_PATH="$HOST_CACHE_PATH_OVERRIDE" +fi SOURCE_PATH="$SCRIPT_DIR/source" +if [[ -n "$HOST_SOURCE_PATH" ]]; then + SOURCE_PATH="$HOST_SOURCE_PATH" +elif [[ -n "$HOST_WORKSPACE_ROOT" ]]; then + SOURCE_PATH="${HOST_WORKSPACE_ROOT%/}/compose_plugin/source" +elif [[ "$in_container" == true && -d "/mnt/user/code/compose_plugin/source" ]]; then + SOURCE_PATH="/mnt/user/code/compose_plugin/source" +fi mkdir -p "$ARCHIVE_PATH" mkdir -p "$HOST_ARCHIVE_PATH" @@ -248,28 +284,15 @@ SOURCE_PATH="$TMP_SOURCE_PATH" echo "Docker will mount SOURCE_PATH=$SOURCE_PATH" # Determine a strategy to provide SOURCE_PATH to the container. -# First attempt direct bind mount; if that fails we fallback to tar stream. - -build_cmd_direct=(docker run --rm --tmpfs /tmp \ - -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ - -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ - -v "$SOURCE_PATH:/mnt/source:ro" \ - -v "$HOST_CA_CERT:$CONTAINER_CA_CERT:ro" \ - -e TZ=America/New_York \ - -e COMPOSE_VERSION="$COMPOSE_VERSION" \ - -e RESVG_VERSION="$RESVG_VERSION" \ - -e RESVG_SHA256="$RESVG_SHA256" \ - -e OUTPUT_FOLDER=/mnt/output \ - -e DOWNLOAD_CACHE_DIR=/mnt/cache \ - -e PKG_VERSION="$VERSION" \ - -e PKG_BUILD="$BUILD_NUM" \ - -e CA_CERT="$CONTAINER_CA_CERT" \ - vbatts/slackware:latest \ - sh -c 'test -f /mnt/source/pkg_build.sh') - -if "${build_cmd_direct[@]}"; then - echo "Direct source mount works. Running build via direct mount..." - if ! docker run --rm --tmpfs /tmp \ +# In nested Docker and CI, direct bind mounts are usually not visible to the daemon, +# so prefer the tar-stream path unless we are on a native host with a visible workspace. +USE_DIRECT_BIND=false +if [[ "$in_container" != true && -z "${CI:-}" && -d "$SOURCE_PATH" ]]; then + USE_DIRECT_BIND=true +fi + +if [[ "$USE_DIRECT_BIND" == true ]]; then + build_cmd_direct=(docker run --rm --tmpfs /tmp \ -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ -v "$SOURCE_PATH:/mnt/source:ro" \ @@ -284,11 +307,35 @@ if "${build_cmd_direct[@]}"; then -e PKG_BUILD="$BUILD_NUM" \ -e CA_CERT="$CONTAINER_CA_CERT" \ vbatts/slackware:latest \ - /mnt/source/pkg_build.sh; then - echo "Docker build failed."; exit 1 + sh -c 'test -f /mnt/source/pkg_build.sh') + + if "${build_cmd_direct[@]}"; then + echo "Direct source mount works. Running build via direct mount..." + if ! docker run --rm --tmpfs /tmp \ + -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ + -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ + -v "$SOURCE_PATH:/mnt/source:ro" \ + -v "$HOST_CA_CERT:$CONTAINER_CA_CERT:ro" \ + -e TZ=America/New_York \ + -e COMPOSE_VERSION="$COMPOSE_VERSION" \ + -e RESVG_VERSION="$RESVG_VERSION" \ + -e RESVG_SHA256="$RESVG_SHA256" \ + -e OUTPUT_FOLDER=/mnt/output \ + -e DOWNLOAD_CACHE_DIR=/mnt/cache \ + -e PKG_VERSION="$VERSION" \ + -e PKG_BUILD="$BUILD_NUM" \ + -e CA_CERT="$CONTAINER_CA_CERT" \ + vbatts/slackware:latest \ + /mnt/source/pkg_build.sh; then + echo "Docker build failed."; exit 1 + fi + else + echo "Direct bind-mount probe failed. Docker is remote or nested; falling back to tar-stream upload." fi -else - echo "Direct mount failed, using tar stream fallback." +fi + +if [[ "$USE_DIRECT_BIND" != true || ! "${build_cmd_direct[@]}" ]]; then + echo "Using tar-stream upload for Docker build (safe for nested Docker and CI)." if ! tar -C "$SOURCE_PATH" -cf - . | docker run --rm --tmpfs /tmp -i \ -v "$HOST_ARCHIVE_PATH:/mnt/output:rw" \ -v "$HOST_CACHE_PATH:/mnt/cache:rw" \ diff --git a/build_in_docker.sh b/build_in_docker.sh index a4a1baa4..a77669ec 100755 --- a/build_in_docker.sh +++ b/build_in_docker.sh @@ -2,7 +2,7 @@ # shellcheck disable=SC1091 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" [ -f "${SCRIPT_DIR}/versions.env" ] && source "${SCRIPT_DIR}/versions.env" -[ -z "$COMPOSE_VERSION" ] && COMPOSE_VERSION=5.1.2 +[ -z "$COMPOSE_VERSION" ] && COMPOSE_VERSION=5.5.0 [ -z "$PKG_VERSION" ] && PKG_VERSION="$(date +%Y.%m.%d)" [ -z "$PKG_BUILD" ] && PKG_BUILD="$(date +%H%M)" mkdir -p "$PWD/archive/.build-cache" diff --git a/compose.manager.plg b/compose.manager.plg index de37a487..f07f7e1c 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,15 +2,15 @@ - + - - + + - + @@ -36,34 +36,12 @@ > -###2026.09.03 -- Features (compose): add wait-for-healthy and wait-timeout settings for stack management -- Features (compose): add follow-logs functionality for live log streaming during compose up by omitting the detach option -- Features (icon): enhance icon caching and management for Docker containers -- Bug Fixes (test): update follow-logs test to check for correct option syntax -- Bug Fixes (dashboard): improve handling of Docker container visibility during loading -- Bug Fixes (icon): harden cache writes and reuse docker ps snapshot -- Bug Fixes (ui): escape identity chooser attributes and restore main channel -- Bug Fixes (ui): show plain warning for multi-stack identity errors -- Bug Fixes (identity): allow logs when identity unresolved -- Bug Fixes (identity): block mutating actions until project identity is proven -- Bug Fixes (identity): add fail-closed legacy project identity resolver -- Bug Fixes (icon): pass data URIs straight to the browser instead of proxying -- Bug Fixes (dashboard): load icon helpers on Dashboard tile (#143) -- Bug Fixes (icon): write icon cache and Docker Manager seeds atomically -- Refactoring (dashboard): extract dashboard JS and hide Docker flash -- Tests (icon): cover icon cache regression cases -- Tests (identity): add regression suite for legacy migration and fail-closed behavior -- Tests (icon): remove both Docker Manager icon copies after seeding tests -- Chores: update changelog for v2026.09.03.2109 [skip ci] -- Chores: update changelog for v2026.09.01.2251 [skip ci] -- Chores: sync pluginURL+README for dev branch [skip ci] -- Chores: sync pluginURL+README for dev branch [skip ci] -- Chores (changelog): split concatenated refactoring entry -- Harden follow-session suspension and add regression checks -- Escape dashboard stack folder attributes -- Fix follow-logs CLI parsing and cleanup trap -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.08.31...v2026.09.03) +###2026.09.13.1636 +- Bug Fixes: set remove-orphans checkbox checked state at render time instead of via setTimeout +- Bug Fixes: stop forced rebuilds on compose update +- Bug Fixes: adjust css so effective command is visible in all themes +- [PR #150](https://github.com/mstrhakr/compose_plugin/pull/150) +- [beta release diff](https://github.com/mstrhakr/compose_plugin/compare/v2026.09.03...v2026.09.13.1636) diff --git a/deploy.sh b/deploy.sh index 5e12a25f..86e8fa83 100755 --- a/deploy.sh +++ b/deploy.sh @@ -6,15 +6,38 @@ trap 'echo "ERROR: command failed on line $LINENO" >&2' ERR VERSION="" DEV=false REMOTE_HOSTS=() -USER_NAME="root" -REMOTE_DIR="/tmp" +USER_NAME="${USER_NAME:-root}" +REMOTE_DIR="${REMOTE_DIR:-/tmp}" PACKAGE_PATH="" SKIP_BUILD=false -COMPOSE_VERSION="5.1.2" +COMPOSE_VERSION="" QUICK=false +if [[ -n "${REMOTE_HOSTS:-}" ]]; then + IFS=',' read -r -a REMOTE_HOSTS <<< "${REMOTE_HOSTS}" +fi + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ARCHIVE_DIR="$SCRIPT_DIR/archive" +ENV_FILE="$SCRIPT_DIR/.env" + +if [[ -f "$SCRIPT_DIR/versions.env" ]]; then + # shellcheck disable=SC1091 + set -a + source "$SCRIPT_DIR/versions.env" + set +a +fi + +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a + source "$ENV_FILE" + set +a +fi + +if [[ -z "${COMPOSE_VERSION:-}" ]]; then + COMPOSE_VERSION="5.1.2" +fi in_container=false if [[ -f "/.dockerenv" ]] || grep -qE '/docker|/lxc|/kubepods' /proc/1/cgroup 2>/dev/null; then diff --git a/source/compose.manager/README.md b/source/compose.manager/README.md index 42be301a..8baaf817 100644 --- a/source/compose.manager/README.md +++ b/source/compose.manager/README.md @@ -1,3 +1,3 @@ -**Compose Manager Plus** +**Compose Manager Plus (Beta)** A plugin for unRAID that installs Docker Compose and adds a management interface to the web UI. diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index 61db6b87..1fbb14aa 100755 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -530,7 +530,24 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' } .compose-status-info { - color: var(--dynamix-sb-title-text-color); + color: var(--text-color, var(--dynamix-sb-title-text-color, #f2f2f2)); + background: var(--dynamix-sb-body-bg-color, var(--background-color, #111)); + } + + #settings-discovery-mode-badge, + #compose-stack-discovery-mode-badge { + display: inline-block; + color: var(--text-color, var(--dynamix-sb-title-text-color, #f2f2f2)); + background: var(--dynamix-sb-body-bg-color, var(--background-color, #111)); + border: 1px solid var(--border-color, var(--dynamix-box-inner-div-border-color, #3b3b3b)); + } + + #settings-discovery-mode-toggle, + #compose-stack-discovery-mode-toggle { + color: var(--black, #111); + background: var(--brand-orange, #f15a24); + border: 1px solid var(--brand-orange, #f15a24); + box-shadow: none; } #aggressive-mode-wrapper { @@ -2746,6 +2763,18 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' +
+
_(Rebuild Images on Update by Default)_:
+
+ > +
+ Default behavior for stack Update actions: when enabled, --build is added so services with a build: section are rebuilt from source. + Leave disabled for stacks that publish an image alongside a build: section — those should be pulled, not rebuilt. + This is a global default and can still be overridden per stack. +
+
+
+
_(New Stack Configuration)_

Default settings applied automatically when creating new compose stacks.

@@ -2905,6 +2934,16 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
_(Advanced Options)_

Performance tuning and troubleshooting options for advanced users.

+
+
_(Don't close editor modal on outside click)_:
+
+ > +
+ When enabled, the Compose editor stays open when you click or drag on the backdrop outside the modal. This keeps editing from being interrupted while selecting or moving large blocks of text. +
+
+
+
_(Debug Logging)_:
diff --git a/source/compose.manager/default.cfg b/source/compose.manager/default.cfg index 681f1391..55725a3c 100755 --- a/source/compose.manager/default.cfg +++ b/source/compose.manager/default.cfg @@ -16,6 +16,7 @@ RUN_IN_BACKGROUND_DEFAULT="false" REMOVE_ORPHANS_DEFAULT="false" WAIT_FOR_HEALTHY_DEFAULT="false" WAIT_FOR_HEALTHY_TIMEOUT_DEFAULT="300" +BUILD_ON_UPDATE_DEFAULT="false" DISABLE_ACTION_WARNINGS="false" NEW_STACK_USE_DEFAULT_COMPOSE_FILES="false" NEW_STACK_OVERRIDE_MANAGEMENT_AUTOMATIC="true" @@ -30,3 +31,4 @@ STACKS_DEFAULT_EXPANDED="false" ONLY_EXPAND_RUNNING_STACKS="false" COMPOSE_STATS_RATE_MODE="live" COMPOSE_STATS_CUSTOM_INTERVAL_MS="1000" +DONT_CLOSE_EDITOR_MODAL_ON_OUTSIDE_CLICK="false" diff --git a/source/compose.manager/event/docker_started b/source/compose.manager/event/docker_started index 0596b126..bfdbdacd 100755 --- a/source/compose.manager/event/docker_started +++ b/source/compose.manager/event/docker_started @@ -23,29 +23,40 @@ wait_for_docker_daemon() { while [ $waited -lt $DOCKER_WAIT_TIMEOUT ]; do if docker info >/dev/null 2>&1; then - log "Docker daemon is ready after ${waited}s" + log "Docker daemon is ready after ${waited}s" debug return 0 fi if [ $waited -eq 0 ]; then - log "Waiting for Docker daemon to become ready..." + log "Waiting for Docker daemon to become ready..." debug fi sleep $check_interval waited=$((waited + check_interval)) done - log "ERROR: Docker daemon was not ready after ${DOCKER_WAIT_TIMEOUT}s" + log "ERROR: Docker daemon was not ready after ${DOCKER_WAIT_TIMEOUT}s" error return 1 } -# Logging helper — writes to log file and delegates to shared composeLogger for syslog +# Logging helper — writes to log file and delegates to shared composeLogger for syslog. +# Accepts an optional log level so debug output can be enabled without changing callers. log() { - local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1" - echo "$msg" >> "$LOG_FILE" - composeLogger "$1" info autostart + local msg="$1" + local level="${2:-info}" + local timestamp="[$(date '+%Y-%m-%d %H:%M:%S')] $msg" + echo "$timestamp" >> "$LOG_FILE" + composeLogger "$msg" "$level" autostart } +# Unraid invokes this event hook synchronously. Spawn a background worker so the array +# can finish transitioning to Started while Compose autostart continues in the background. +if [ "${COMPOSE_MANAGER_AUTOSTART_CHILD:-0}" != "1" ]; then + composeLogger "Autostart event received; detaching background worker to avoid blocking array startup" debug autostart + nohup env COMPOSE_MANAGER_AUTOSTART_CHILD=1 "$0" >/dev/null 2>&1 & + exit 0 +fi + # Load the saved UI stack order for the current compose root. # Prints "projectposition" lines when a saved order exists. load_saved_stack_order() { @@ -90,7 +101,7 @@ foreach (array_values($order) as $index => $project) { # Wait for Docker autostart containers to finish starting wait_for_docker_autostart() { - log "Waiting for Docker autostart containers to stabilize..." + log "Waiting for Docker autostart containers to stabilize..." debug local waited=0 local check_interval=5 @@ -111,13 +122,13 @@ wait_for_docker_autostart() { if [ $total_starting -eq 0 ]; then stable_count=$((stable_count + 1)) if [ $stable_count -ge $required_stable ]; then - log "Docker containers stabilized after ${waited}s" + log "Docker containers stabilized after ${waited}s" debug return 0 fi else stable_count=0 if [ $total_starting -ne $last_starting ]; then - log "Waiting for Docker: $total_starting containers still starting..." + log "Waiting for Docker: $total_starting containers still starting..." debug last_starting=$total_starting fi fi @@ -126,7 +137,7 @@ wait_for_docker_autostart() { waited=$((waited + check_interval)) done - log "WARNING: Docker wait timeout after ${DOCKER_WAIT_TIMEOUT}s, proceeding anyway" + log "WARNING: Docker wait timeout after ${DOCKER_WAIT_TIMEOUT}s, proceeding anyway" warning return 1 } @@ -170,7 +181,7 @@ start_stack() { local stack_name="$2" local start_time=$(date +%s) - log "Starting stack: $stack_name" + log "Starting stack: $stack_name" debug local project project=$(basename "$dir") @@ -179,7 +190,7 @@ start_stack() { local -a cmd_args=("$COMPOSE_WRAPPER" -c up) if ! load_compose_action_spec "$COMPOSE_ROOT" "$project" up "$dir"; then local reason="${COMPOSE_SPEC_ERROR_MESSAGE:-compose args provider failed}" - log "ERROR: Failed to resolve compose args for stack '$stack_name': $reason; skipping startup" + log "ERROR: Failed to resolve compose args for stack '$stack_name': $reason; skipping startup" error return 1 fi @@ -227,32 +238,32 @@ start_stack() { local duration=$(($(date +%s) - start_time)) if [ $exit_code -eq 0 ]; then - log "Stack $stack_name started successfully in ${duration}s" + log "Stack $stack_name started successfully in ${duration}s" debug return 0 elif [ $exit_code -eq 124 ]; then - log "ERROR: Stack $stack_name startup timed out after ${STARTUP_TIMEOUT}s" + log "ERROR: Stack $stack_name startup timed out after ${STARTUP_TIMEOUT}s" error return 124 else - log "ERROR: Stack $stack_name failed to start (exit code: $exit_code)" + log "ERROR: Stack $stack_name failed to start (exit code: $exit_code)" error # Log last few lines of output for debugging echo "$output" | tail -5 | while read line; do - [ -n "$line" ] && log " $stack_name: $line" + [ -n "$line" ] && log " $stack_name: $line" debug done return $exit_code fi } # Main execution -log "=== Compose Manager Autostart Begin ===" +log "=== Compose Manager Autostart Begin ===" debug if [ -z "$COMPOSE_ROOT" ] || [ ! -d "$COMPOSE_ROOT" ]; then - log "ERROR: Invalid compose root path: '$COMPOSE_ROOT'" + log "ERROR: Invalid compose root path: '$COMPOSE_ROOT'" error exit 1 fi # Always wait for Docker daemon readiness to avoid early-boot startup races. if ! wait_for_docker_daemon; then - log "=== Compose Manager Autostart Aborted (docker unavailable) ===" + log "=== Compose Manager Autostart Aborted (docker unavailable) ===" error exit 1 fi @@ -299,10 +310,10 @@ done | sort -t$'\t' -k1,1n -k2,2f | cut -f2-)) unset IFS total_stacks=${#sorted_stacks[@]} -log "Found $total_stacks stacks with autostart enabled" +log "Found $total_stacks stacks with autostart enabled" debug if [ $total_stacks -eq 0 ]; then - log "=== Compose Manager Autostart Complete (no stacks) ===" + log "=== Compose Manager Autostart Complete (no stacks) ===" debug exit 0 fi @@ -317,7 +328,7 @@ for stack_entry in "${sorted_stacks[@]}"; do stack_name="${stack_entry#*|}" current=$((current + 1)) - log "[$current/$total_stacks] Processing: $stack_name" + log "[$current/$total_stacks] Processing: $stack_name" debug # Start stack and wait for it to complete before moving to next start_stack "$dir" "$stack_name" @@ -330,8 +341,8 @@ for stack_entry in "${sorted_stacks[@]}"; do esac done -log "=== Compose Manager Autostart Complete ===" -log "Results: $succeeded succeeded, $failed failed, $timedout timed out (of $total_stacks total)" +log "=== Compose Manager Autostart Complete ===" debug +log "Results: $succeeded succeeded, $failed failed, $timedout timed out (of $total_stacks total)" debug # Exit with error if any failed (for logging purposes, doesn't affect Unraid) if [ $failed -gt 0 ] || [ $timedout -gt 0 ]; then diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index fa45e1b2..5f6f8306 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -781,6 +781,15 @@ function compose_manager_cpu_spec_count($cpuSpec)
Seconds to wait for the stack to become healthy. Leave empty to use the global default timeout.
+ +
+ + +
Enable only for stacks whose images are built locally. Leave unchecked for stacks that publish an image alongside a build: section — those are pulled instead, matching plain docker compose up.
+
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index b11ec6d6..afc91688 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1015,6 +1015,10 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $waitTimeoutFile = "$compose_root/$script/wait_timeout"; $waitTimeout = is_file($waitTimeoutFile) ? trim(file_get_contents($waitTimeoutFile)) : ""; + // Get rebuild-on-update override + $buildOnUpdateFile = "$compose_root/$script/build_on_update"; + $buildOnUpdate = is_file($buildOnUpdateFile) ? trim(file_get_contents($buildOnUpdateFile)) : ""; + // Get additional compose files (one path per line) $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : ""; @@ -1104,6 +1108,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'defaultProfile' => $defaultProfile, 'waitForHealthy' => ($waitForHealthy === 'true' || $waitForHealthy === '1'), 'waitTimeout' => $waitTimeout, + 'buildOnUpdate' => ($buildOnUpdate === 'true' || $buildOnUpdate === '1'), 'extraComposeFiles' => $extraComposeFiles, 'composeFileCandidates' => $composeFileCandidates, 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(), @@ -1217,6 +1222,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $defaultProfile = isset($_POST['defaultProfile']) ? trim($_POST['defaultProfile']) : ""; $waitForHealthy = isset($_POST['waitForHealthy']) ? strtolower(trim((string) $_POST['waitForHealthy'])) : "false"; $waitTimeout = isset($_POST['waitTimeout']) ? trim((string) $_POST['waitTimeout']) : ""; + $buildOnUpdate = isset($_POST['buildOnUpdate']) ? strtolower(trim((string) $_POST['buildOnUpdate'])) : "false"; $useDefaultComposeFiles = isset($_POST['useDefaultComposeFiles']) && strtolower(trim((string) $_POST['useDefaultComposeFiles'])) === 'true'; @@ -1374,6 +1380,16 @@ function composeResolveContainerIcon(string $containerName, string $service, arr } } + // Set stack rebuild-on-update override + $buildOnUpdateFile = "$compose_root/$script/build_on_update"; + if ($buildOnUpdate === 'true' || $buildOnUpdate === '1') { + file_put_contents($buildOnUpdateFile, 'true'); + } elseif ($buildOnUpdate === 'false' || $buildOnUpdate === '0' || $buildOnUpdate === '') { + if (is_file($buildOnUpdateFile)) { + @unlink($buildOnUpdateFile); + } + } + // Set additional compose files (skipped entirely when the field was not sent) if ($extraComposeFilesProvided) { $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index d16a49ee..94ad947a 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -207,6 +207,29 @@ function resolveStackWaitSettings(string $stackPath, array $cfg): array return ['enabled' => $enabled, 'timeout' => $timeout, 'stackName' => $stackName]; } +/** + * Resolve whether `update` should rebuild buildable services for a stack. + * + * Compose only builds automatically when an image is missing, so forcing + * `--build` breaks stacks that publish an image alongside an unbuildable + * `build:` section (see issue #149). Opt-in per stack, global default otherwise. + * + * @param array $cfg + */ +function resolveStackBuildOnUpdate(string $stackPath, array $cfg): bool +{ + $buildFile = rtrim($stackPath, '/') . '/build_on_update'; + + if (is_file($buildFile)) { + $raw = trim((string) file_get_contents($buildFile)); + if ($raw !== '') { + return ($raw === 'true' || $raw === '1'); + } + } + + return (($cfg['BUILD_ON_UPDATE_DEFAULT'] ?? 'false') === 'true'); +} + /** * Build and echo a compose command for a single stack. * @@ -238,6 +261,7 @@ function echoComposeCommand($action, array $options = []) $followLogs = !empty($options['followLogs']); $waitForHealthy = false; $waitTimeout = (string) ($cfg['WAIT_FOR_HEALTHY_TIMEOUT_DEFAULT'] ?? '300'); + $buildOnUpdate = ($action === 'update') && resolveStackBuildOnUpdate($path, $cfg); if ($action === 'up') { $resolvedWait = resolveStackWaitSettings($path, $cfg); $waitForHealthy = !empty($resolvedWait['enabled']); @@ -326,6 +350,10 @@ function echoComposeCommand($action, array $options = []) $composeCommand[] = "--follow-logs"; } + if ($buildOnUpdate) { + $composeCommand[] = "--build"; + } + if ($action === 'up' && $waitForHealthy) { if ($followLogs) { composeLogger("Blocked wait-for-healthy with follow logs enabled", ['action' => $action, 'path' => $path], 'user', 'warning', 'compose'); diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 8ee8147d..cd2fd5b7 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -2189,13 +2189,12 @@ private function __construct(string $composeRoot, string $projectFolder) $this->composeSource = dirname($this->indirectPath); } else { $this->composeSource = $this->indirectPath; - $this->composeFilePath = self::getComposeFilePath($this->composeSource); + $this->composeFilePath = self::getComposeFilePath($this->composeSource, $this->path); } } else { $this->composeSource = $this->path; - $this->composeFilePath = self::getComposeFilePath($this->composeSource); + $this->composeFilePath = self::getComposeFilePath($this->composeSource, $this->path); } - if ($this->invalidIndirectPath === null && $this->indirectPath !== null && $this->indirectPath !== '' && $this->composeFilePath === null && $this->isIndirect) { // Preserve the broken indirect target for repair flows. $this->invalidIndirectPath = $this->indirectPath; @@ -2267,11 +2266,16 @@ public static function clearCache(?string $key = null): void * Checks for compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml * in that order and returns the first one found. * + * @param string $path The compose source directory (where to look for compose files and .env) + * @param string|null $stackPath Optional stack metadata directory (where to look for envpath metadata); + * defaults to $path if not provided * @return string|null Full path to the compose file if found, or null if none found */ - private static function getComposeFilePath($path): string|null + private static function getComposeFilePath(string $path, ?string $stackPath = null): string|null { - if (is_string($path) && is_file($path)) { + $stackPath = $stackPath ?? $path; + + if (is_file($path)) { return preg_match('/\.ya?ml$/i', basename($path)) === 1 ? $path : null; } @@ -2282,7 +2286,93 @@ private static function getComposeFilePath($path): string|null break; } } - return $composeFilePath; + if ($composeFilePath !== null) { + return $composeFilePath; + } + + $envFilePath = self::resolveProjectEnvFilePath($path, $stackPath); + if ($envFilePath === null) { + return null; + } + + return self::resolveComposeFileFromEnvFile($envFilePath); + } + + /** + * Resolve the active env file for a project root, respecting explicit + * envpath metadata before falling back to the local .env file. + * + * @param string $path Compose source directory (where to look for .env) + * @param string $stackPath Stack directory (where to look for envpath metadata) + * @return string|null Resolved env file path or null if none is usable + */ + private static function resolveProjectEnvFilePath(string $path, string $stackPath): ?string + { + $stackDir = rtrim($stackPath, '/'); + $composeDir = rtrim($path, '/'); + + // Check for explicit envpath metadata in stack directory + $envPathMetadata = $stackDir . '/envpath'; + if (is_file($envPathMetadata)) { + $raw = @file_get_contents($envPathMetadata); + $candidate = $raw === false ? '' : trim($raw); + if ($candidate !== '') { + $resolved = $candidate; + if (!Path::isAbsolutePath($resolved)) { + $resolved = $stackDir . '/' . $resolved; + } + if (is_file($resolved)) { + return realpath($resolved) ?: $resolved; + } + } + } + + // Fall back to .env in compose directory + $defaultEnvPath = $composeDir . '/.env'; + return is_file($defaultEnvPath) ? $defaultEnvPath : null; + } + + /** + * Resolve the first valid compose file declared by COMPOSE_FILE in an env file. + * + * @param string $envFilePath + * @return string|null + */ + private static function resolveComposeFileFromEnvFile(string $envFilePath): ?string + { + $content = @file_get_contents($envFilePath); + if ($content === false) { + return null; + } + + $envDir = dirname($envFilePath); + foreach (preg_split('/\R/', $content) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) { + continue; + } + if (!str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + if (trim($key) !== 'COMPOSE_FILE') { + continue; + } + + foreach (self::splitComposeFileValue(trim($value)) as $entry) { + $entry = Strings::stripQuotes(trim($entry)); + if ($entry === '') { + continue; + } + $candidate = Path::isAbsolutePath($entry) ? $entry : $envDir . '/' . $entry; + if (is_file($candidate) && preg_match('/\.ya?ml$/i', basename($candidate)) === 1) { + return realpath($candidate) ?: $candidate; + } + } + break; + } + + return null; } @@ -2525,6 +2615,9 @@ public function getEffectiveEnvFilePath(): ?string /** * Resolve a valid explicit envpath configured in stack metadata. * + * Supports both absolute and relative paths. Relative paths are resolved + * relative to the stack directory ($this->path). + * * @return string|null */ private function getExplicitEnvFilePath(): ?string @@ -2539,10 +2632,19 @@ private function getExplicitEnvFilePath(): ?string return null; } + // Try as absolute path first if (is_file($rawEnvPath)) { return realpath($rawEnvPath) ?: $rawEnvPath; } + // Try as relative to stack directory + if (!Path::isAbsolutePath($rawEnvPath)) { + $relativePath = $this->path . '/' . $rawEnvPath; + if (is_file($relativePath)) { + return realpath($relativePath) ?: $relativePath; + } + } + composeLogger('Explicit envpath is set but not resolvable to a file; falling back to default env resolution', [ 'project' => $this->projectFolder, 'envpath' => $rawEnvPath, diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 78cd1544..27cc246d 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -1277,7 +1277,7 @@ function initEditorModal() { editorModal.editors['override'] = overrideEditor; // Initialize settings field change tracking - $('#settings-name, #settings-description, #settings-icon-url, #settings-webui-url, #settings-env-path, #settings-default-profile, #settings-wait-for-healthy, #settings-wait-timeout, #settings-external-compose-path, #settings-external-compose-file, #settings-use-default-compose-files').on('input change', function() { + $('#settings-name, #settings-description, #settings-icon-url, #settings-webui-url, #settings-env-path, #settings-default-profile, #settings-wait-for-healthy, #settings-wait-timeout, #settings-build-on-update, #settings-external-compose-path, #settings-external-compose-file, #settings-use-default-compose-files').on('input change', function() { var fieldId = this.id.replace('settings-', ''); var isCheckbox = this.type === 'checkbox'; var currentValue = isCheckbox ? ($(this).is(':checked') ? 'true' : 'false') : $(this).val(); @@ -1460,9 +1460,15 @@ function initEditorModal() { // Close modal when clicking on the overlay background (not the inner modal content) $('#editor-modal-overlay').off('click.editorModal').on('click.editorModal', function(e) { - if (e.target === this) { - closeEditorModal(); + if (e.target !== this) { + return; } + getConfig().then(function(cfg) { + if (cfg.DONT_CLOSE_EDITOR_MODAL_ON_OUTSIDE_CLICK === 'true') { + return; + } + closeEditorModal(); + }); }); } @@ -3240,10 +3246,34 @@ $(function() { if (!isCurrentComposeDockerLoad(composeDockerLoad, newGeneration)) { return; } - composeLogger('WebSocket error', { - code: code, - desc: desc - }, 'user', 'warn', 'dockerload'); + + var socketError = {}; + if (code && typeof code === 'object') { + socketError.type = code.type || null; + socketError.message = code.message || null; + socketError.readyState = (code.target && code.target.readyState !== undefined) ? code.target.readyState : null; + if (code.code !== undefined && code.code !== null) { + socketError.code = code.code; + } + } else if (code !== undefined && code !== null) { + socketError.code = code; + } + + if (desc && typeof desc === 'object') { + if (desc.type) { + socketError.descType = desc.type; + } + if (desc.readyState !== undefined && desc.readyState !== null) { + socketError.descReadyState = desc.readyState; + } + if (desc.message) { + socketError.descMessage = desc.message; + } + } else if (desc !== undefined && desc !== null) { + socketError.desc = desc; + } + + composeLogger('WebSocket reconnect/error', socketError, 'user', 'debug', 'dockerload'); }); // If dockerload pauses/stalls, drop stale values on a timer so the UI @@ -3854,9 +3884,10 @@ function isStackRunning(project) { return $stackRow.length > 0 && $stackRow.data('isup') == '1'; } -function buildRemoveOrphansCheckboxHtml(checkboxId) { +function buildRemoveOrphansCheckboxHtml(checkboxId, checked) { + var checkedAttr = checked ? ' checked' : ''; return '
' + - '' + + '' + '' + '
'; } @@ -4870,12 +4901,11 @@ function startAllStacks() { '' + '' + ''; - var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-startall'); - getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-startall', removeOrphansDefault); if (disableWarnings) { executeStartAllStacks({ @@ -4911,8 +4941,6 @@ function startAllStacks() { setTimeout(function() { var $cb = $('#swal-run-bg-startall'); if ($cb.length) $cb.prop('checked', bgDefault); - var $removeCb = $('#swal-remove-orphans-startall'); - if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } @@ -5008,12 +5036,11 @@ function stopAllStacks() { '' + '' + ''; - var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-stopall'); - getConfig().then(function(pluginCfg) { var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; var removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; + var removeOrphansHtml = buildRemoveOrphansCheckboxHtml('swal-remove-orphans-stopall', removeOrphansDefault); if (disableWarnings) { executeStopAllStacks({ @@ -5049,8 +5076,6 @@ function stopAllStacks() { setTimeout(function() { var $cb = $('#swal-run-bg-stopall'); if ($cb.length) $cb.prop('checked', bgDefault); - var $removeCb = $('#swal-remove-orphans-stopall'); - if ($removeCb.length) $removeCb.prop('checked', removeOrphansDefault); }, 50); }); } @@ -5463,7 +5488,6 @@ function renderStackActionDialog(action, displayName, path, profile, containers, var bgDefault = pluginCfg && pluginCfg.RUN_IN_BACKGROUND_DEFAULT === 'true'; removeOrphansDefault = pluginCfg && pluginCfg.REMOVE_ORPHANS_DEFAULT === 'true'; var disableWarnings = pluginCfg && pluginCfg.DISABLE_ACTION_WARNINGS === 'true'; - var stackMismatchDetected = !!showRemoveOrphans; if (disableWarnings) { // In default background mode (warnings disabled and background enabled), don't show toast if background is used @@ -5476,8 +5500,8 @@ function renderStackActionDialog(action, displayName, path, profile, containers, return; } - var removeOrphansChecked = removeOrphansDefault || stackMismatchDetected; - var showRemoveOrphansOption = !!cfg.showRemoveOrphans || stackMismatchDetected; + var removeOrphansChecked = removeOrphansDefault; + var showRemoveOrphansOption = !!cfg.showRemoveOrphans; // Use native swal (SweetAlert 1.x) with callback style swal({ @@ -6264,6 +6288,11 @@ function loadSettingsData(project, projectName) { $('#settings-wait-timeout').val(waitTimeout); editorModal.originalSettings['wait-timeout'] = waitTimeout; + // Rebuild-on-update setting + var buildOnUpdate = response.buildOnUpdate === true || response.buildOnUpdate === 'true' || response.buildOnUpdate === '1'; + $('#settings-build-on-update').prop('checked', buildOnUpdate); + editorModal.originalSettings['build-on-update'] = buildOnUpdate ? 'true' : 'false'; + // Compose file discovery mode var useDefaultComposeFiles = response.useDefaultComposeFiles === true; $('#settings-use-default-compose-files').prop('checked', useDefaultComposeFiles); @@ -6311,6 +6340,7 @@ function loadSettingsData(project, projectName) { $('#settings-default-profile').val(''); $('#settings-wait-for-healthy').prop('checked', false); $('#settings-wait-timeout').val(''); + $('#settings-build-on-update').prop('checked', false); $('#settings-external-compose-path').val(''); $('#settings-external-compose-file').val(''); $('#settings-use-default-compose-files').prop('checked', false); @@ -7263,6 +7293,7 @@ function saveSettings(saveErrors) { var defaultProfile = $('#settings-default-profile').val(); var waitForHealthy = $('#settings-wait-for-healthy').is(':checked') ? 'true' : 'false'; var waitTimeout = $('#settings-wait-timeout').val(); + var buildOnUpdate = $('#settings-build-on-update').is(':checked') ? 'true' : 'false'; var externalComposePath = $('#settings-external-compose-path').val(); var externalComposeFilePath = $('#settings-external-compose-file').val(); var useDefaultComposeFiles = $('#settings-use-default-compose-files').is(':checked') ? 'true' : 'false'; @@ -7277,6 +7308,7 @@ function saveSettings(saveErrors) { defaultProfile: defaultProfile, waitForHealthy: waitForHealthy, waitTimeout: waitTimeout, + buildOnUpdate: buildOnUpdate, externalComposePath: externalComposePath, externalComposeFilePath: externalComposeFilePath, useDefaultComposeFiles: useDefaultComposeFiles @@ -7296,6 +7328,7 @@ function saveSettings(saveErrors) { editorModal.originalSettings['default-profile'] = defaultProfile; editorModal.originalSettings['wait-for-healthy'] = waitForHealthy; editorModal.originalSettings['wait-timeout'] = waitTimeout; + editorModal.originalSettings['build-on-update'] = buildOnUpdate; editorModal.originalSettings['external-compose-path'] = externalComposePath; editorModal.originalSettings['external-compose-file'] = externalComposeFilePath; editorModal.originalSettings['use-default-compose-files'] = useDefaultComposeFiles; @@ -7306,6 +7339,7 @@ function saveSettings(saveErrors) { editorModal.modifiedSettings.delete('default-profile'); editorModal.modifiedSettings.delete('wait-for-healthy'); editorModal.modifiedSettings.delete('wait-timeout'); + editorModal.modifiedSettings.delete('build-on-update'); editorModal.modifiedSettings.delete('external-compose-path'); editorModal.modifiedSettings.delete('external-compose-file'); editorModal.modifiedSettings.delete('use-default-compose-files'); diff --git a/source/compose.manager/scripts/compose.sh b/source/compose.manager/scripts/compose.sh index 5f4f3cb8..c4e40a5e 100755 --- a/source/compose.manager/scripts/compose.sh +++ b/source/compose.manager/scripts/compose.sh @@ -12,7 +12,7 @@ LOCK_TIMEOUT=${COMPOSE_LOCK_TIMEOUT:-30} LOCK_DIR="/var/run/compose.manager" SHORT=e:,c:,f:,p:,d:,o:,g:,s:,w: -LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,remove-orphans,stack-path:,workdir:,follow-logs,wait,wait-timeout: +LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,remove-orphans,stack-path:,workdir:,follow-logs,wait,wait-timeout:,build OPTS=$(getopt -a -n compose --options $SHORT --longoptions $LONG -- "$@") eval set -- "$OPTS" @@ -22,13 +22,14 @@ env_args=() file_args=() profile_names=() profile_args=() -project_dir_args=() +project_directory="" cmd_args=() stack_path="" debug=false follow_logs=false wait_for_healthy=false wait_timeout="" +build_on_update=false lock_fd="" operation_exit_code=0 @@ -169,7 +170,7 @@ do ;; -w | --workdir ) if [ -d "$2" ]; then - project_dir_args=("--project-directory" "$2") + project_directory="$2" else log_msg "ERROR" "Project directory does not exist: $2" exit 1 @@ -204,6 +205,10 @@ do wait_timeout="$2" shift 2 ;; + --build ) + build_on_update=true + shift; + ;; --) shift; break @@ -220,8 +225,18 @@ for profile_name in "${profile_names[@]}"; do profile_args+=("--profile" "$profile_name") done -# Build the compose base command as an array (no eval needed) -compose_base=(docker compose "${project_dir_args[@]}" "${env_args[@]}" "${file_args[@]}" "${profile_args[@]}") +if [ -n "$project_directory" ]; then + if ! cd "$project_directory" 2>/dev/null; then + log_msg "ERROR" "Failed to cd into project directory: $project_directory" + exit 1 + fi +fi + +# Build the compose base command as an array (no eval needed). +# When we need Docker Compose default discovery we intentionally run from the +# project directory itself so it matches plain `cd && docker compose ...`, +# which is the behavior the project was validated against. +compose_base=(docker compose "${env_args[@]}" "${file_args[@]}" "${profile_args[@]}") # Canonicalize project name through shared PHP sanitizer. if ! name=$(canonicalize_project_name "$name"); then @@ -335,10 +350,18 @@ case $command in ;; update) + up_args=("-d") + pull_args=() + if [ "$build_on_update" = true ]; then + # --ignore-buildable only makes sense when we rebuild those services ourselves. + pull_args+=("--ignore-buildable") + up_args+=("--build") + fi + if [ "$debug" = true ]; then log_msg "DEBUG" "${compose_base[*]} -p $name images -q" - log_msg "DEBUG" "${compose_base[*]} -p $name pull --ignore-buildable" - log_msg "DEBUG" "${compose_base[*]} -p $name up -d --build" + log_msg "DEBUG" "${compose_base[*]} -p $name pull ${pull_args[*]}" + log_msg "DEBUG" "${compose_base[*]} -p $name up ${up_args[*]}" fi # Capture current images for cleanup later @@ -364,9 +387,9 @@ case $command in images=( "${images[@]##sha256:}" ) fi - # Pull latest images (--ignore-buildable: skip services with build sections, they are rebuilt by up --build) + # Pull latest images. Buildable services are only skipped when we rebuild them below. echo "Pulling latest images..." - "${compose_base[@]}" -p "$name" pull --ignore-buildable + "${compose_base[@]}" -p "$name" pull "${pull_args[@]}" pull_exit=$? if [ $pull_exit -ne 0 ]; then @@ -381,7 +404,7 @@ case $command in # Recreate containers with new images echo "" echo "Recreating containers..." - "${compose_base[@]}" -p "$name" up -d --build + "${compose_base[@]}" -p "$name" up "${up_args[@]}" up_exit=$? if [ $up_exit -eq 0 ]; then diff --git a/source/compose.manager/sheets/EditorModal.css b/source/compose.manager/sheets/EditorModal.css index eb158eff..1984db17 100644 --- a/source/compose.manager/sheets/EditorModal.css +++ b/source/compose.manager/sheets/EditorModal.css @@ -306,10 +306,10 @@ } .settings-effective-command-wrap { - border: 1px solid var(--dynamix-box-inner-div-border-color, var(--border-color)); + border: 1px solid var(--border-color, var(--dynamix-box-inner-div-border-color)); border-radius: 6px; padding: 14px 16px; - background-color: var(--dynamix-sb-body-bg-color, var(--background-color)); + background-color: var(--background-color, var(--dynamix-sb-body-bg-color)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); } @@ -323,8 +323,11 @@ line-height: 1.5; white-space: pre-wrap; word-break: break-all; - color: var(--text-color); - background: transparent; + color: var(--text-color, var(--dynamix-sb-body-text-color, #111)); + background-color: var(--background-color, var(--dynamix-sb-body-bg-color, #fff)); + border: 1px solid var(--border-color, var(--dynamix-box-inner-div-border-color)); + border-radius: 4px; + padding: 8px 10px; text-shadow: none; } diff --git a/source/pkg_build.sh b/source/pkg_build.sh index 905d1d6e..9f35f8ee 100755 --- a/source/pkg_build.sh +++ b/source/pkg_build.sh @@ -130,13 +130,35 @@ wget_args() { echo "${args[@]}" } +resolve_latest_slackware_package() { + local package_prefix="$1" + local package_index + local package_name="" + + package_index="$(mktemp)" + if ! wget $(wget_args) -q -O "$package_index" "https://slackware.osuosl.org/slackware64-current/slackware64/a/"; then + echo "Failed to fetch Slackware package index while resolving ${package_prefix}." | tee -a "$LOG_FILE" + rm -f "$package_index" + exit 9 + fi + + package_name="$(grep -Eo "${package_prefix}[^\"'[:space:]]+\\.txz" "$package_index" | sort -V | tail -n 1 || true)" + rm -f "$package_index" + + if [[ -z "$package_name" ]]; then + echo "No ${package_prefix} package found in the Slackware current package index." | tee -a "$LOG_FILE" + exit 10 + fi + + echo "$package_name" +} + echo "Installing unzip dependency..." -INFOZIP_PKG="infozip-6.0-x86_64-8.txz" -download_with_sha_cache \ - "https://slackware.osuosl.org/slackware64-current/slackware64/a/${INFOZIP_PKG}" \ - "" \ - "$INFOZIP_PKG" \ - "2df6d72a3662be939fb533564b1b1e6d4fedd1e2cbddaa8d39627509a397d4d3" +INFOZIP_PKG="$(resolve_latest_slackware_package "infozip")" +INFOZIP_URL="https://slackware.osuosl.org/slackware64-current/slackware64/a/${INFOZIP_PKG}" + +echo "Resolved latest infozip package: ${INFOZIP_PKG}" | tee -a "$LOG_FILE" +download_file_quiet "$INFOZIP_URL" "$INFOZIP_PKG" "infozip package" run_quiet upgradepkg --install-new "${INFOZIP_PKG}" echo "Creating temporary package structure at $tmpdir..." diff --git a/tests/unit/ComposeProjectDirectoryArgsTest.php b/tests/unit/ComposeProjectDirectoryArgsTest.php new file mode 100644 index 00000000..d8d1d362 --- /dev/null +++ b/tests/unit/ComposeProjectDirectoryArgsTest.php @@ -0,0 +1,121 @@ +stackRoot = sys_get_temp_dir() . '/compose_projdir_test_' . getmypid(); + @mkdir($this->stackRoot . '/docker', 0755, true); + file_put_contents($this->stackRoot . '/docker/docker-compose.yml', "services: {}\n"); + file_put_contents($this->stackRoot . '/docker/docker-compose-ipinfo.yml', "services: {}\n"); + } + + protected function tearDown(): void + { + @unlink($this->stackRoot . '/build_on_update'); + @unlink($this->stackRoot . '/docker/docker-compose.yml'); + @unlink($this->stackRoot . '/docker/docker-compose-ipinfo.yml'); + @rmdir($this->stackRoot . '/docker'); + @rmdir($this->stackRoot); + + parent::tearDown(); + } + + public function testExplicitComposeFilesDoNotEmitProjectDirectory(): void + { + $composeCommand = []; + \appendComposeFileArgs($composeCommand, [ + 'projectDirectory' => $this->stackRoot, + 'useDefaultFileDiscovery' => false, + 'filePaths' => [ + $this->stackRoot . '/docker/docker-compose.yml', + $this->stackRoot . '/docker/docker-compose-ipinfo.yml', + ], + ]); + + $this->assertSame([ + '-f' . $this->stackRoot . '/docker/docker-compose.yml', + '-f' . $this->stackRoot . '/docker/docker-compose-ipinfo.yml', + ], $composeCommand); + } + + public function testDefaultDiscoveryEmitsOnlyProjectDirectory(): void + { + $composeCommand = []; + \appendComposeFileArgs($composeCommand, [ + 'projectDirectory' => $this->stackRoot, + 'useDefaultFileDiscovery' => true, + 'filePaths' => [$this->stackRoot . '/docker/docker-compose.yml'], + ]); + + $this->assertSame(['-w' . $this->stackRoot], $composeCommand); + } + + public function testDefaultDiscoveryWithoutProjectDirectoryEmitsNothing(): void + { + $composeCommand = []; + \appendComposeFileArgs($composeCommand, [ + 'projectDirectory' => '', + 'useDefaultFileDiscovery' => true, + 'filePaths' => [$this->stackRoot . '/docker/docker-compose.yml'], + ]); + + $this->assertSame([], $composeCommand); + } + + public function testNonexistentComposeFilesAreSkipped(): void + { + $composeCommand = []; + \appendComposeFileArgs($composeCommand, [ + 'projectDirectory' => $this->stackRoot, + 'useDefaultFileDiscovery' => false, + 'filePaths' => [$this->stackRoot . '/docker/missing.yml'], + ]); + + $this->assertSame([], $composeCommand); + } + + public function testBuildOnUpdateDefaultsToGlobalSetting(): void + { + $this->assertFalse(\resolveStackBuildOnUpdate($this->stackRoot, [])); + $this->assertFalse(\resolveStackBuildOnUpdate($this->stackRoot, ['BUILD_ON_UPDATE_DEFAULT' => 'false'])); + $this->assertTrue(\resolveStackBuildOnUpdate($this->stackRoot, ['BUILD_ON_UPDATE_DEFAULT' => 'true'])); + } + + public function testBuildOnUpdateStackOverrideWinsOverGlobal(): void + { + $overrideFile = $this->stackRoot . '/build_on_update'; + + file_put_contents($overrideFile, "true\n"); + $this->assertTrue(\resolveStackBuildOnUpdate($this->stackRoot, ['BUILD_ON_UPDATE_DEFAULT' => 'false'])); + + file_put_contents($overrideFile, "false\n"); + $this->assertFalse(\resolveStackBuildOnUpdate($this->stackRoot, ['BUILD_ON_UPDATE_DEFAULT' => 'true'])); + + file_put_contents($overrideFile, ""); + $this->assertTrue(\resolveStackBuildOnUpdate($this->stackRoot, ['BUILD_ON_UPDATE_DEFAULT' => 'true'])); + + @unlink($overrideFile); + } +} diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index d6b768a1..d75ca56c 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -1113,6 +1113,70 @@ public function testBuildComposeArgsWithComposeFileInEnv(): void $this->assertContains($stackDir . '/compose.debug.yaml', $args['filePaths']); } + public function testProjectLoadsWhenComposeFileIsOnlyDeclaredInDotEnv(): void + { + $stack = 'env-compose-file-only'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir . '/docker', 0755, true); + file_put_contents("$stackDir/.env", "COMPOSE_FILE=docker/docker-compose.yml\n"); + file_put_contents("$stackDir/docker/docker-compose.yml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame($stackDir . '/docker/docker-compose.yml', $info->composeFilePath); + $this->assertContains($stackDir . '/docker/docker-compose.yml', $info->getEditableComposeFiles()); + $this->assertStringContainsString('docker-compose.yml', $info->buildComposeArgs()['files']); + } + + public function testIndirectStackLoadsWhenComposeFileViaEnvpath(): void + { + $stack = 'indirect-env-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectDir = $this->tempRoot . '/indirect_source'; + mkdir($stackDir); + mkdir($indirectDir . '/config', 0755, true); + + // Create indirect stack with envpath metadata + file_put_contents("$stackDir/indirect", $indirectDir); + file_put_contents("$stackDir/envpath", "$indirectDir/config/.env"); + + // Create .env file at indirect location with COMPOSE_FILE declaration + file_put_contents("$indirectDir/config/.env", "COMPOSE_FILE=../docker-compose.yml\n"); + file_put_contents("$indirectDir/docker-compose.yml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertTrue($info->isIndirect); + $this->assertSame($indirectDir, $info->composeSource); + $this->assertSame($indirectDir . '/docker-compose.yml', $info->composeFilePath); + $this->assertContains($indirectDir . '/docker-compose.yml', $info->getEditableComposeFiles()); + } + + public function testIndirectStackLoadsWhenComposeFileViaRelativeEnvpath(): void + { + $stack = 'indirect-relative-env'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectDir = $this->tempRoot . '/another_indirect'; + mkdir($stackDir); + mkdir($indirectDir); + + // Create indirect stack with relative envpath metadata + file_put_contents("$stackDir/indirect", $indirectDir); + file_put_contents("$stackDir/envpath", "config/.env"); // relative path in stack dir + + // Create the referenced .env file in stack directory + mkdir("$stackDir/config", 0755, true); + file_put_contents("$stackDir/config/.env", "COMPOSE_FILE=../docker-compose.yml\n"); + file_put_contents("$stackDir/docker-compose.yml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertTrue($info->isIndirect); + $this->assertSame($indirectDir, $info->composeSource); + // The compose file is discovered via COMPOSE_FILE in the referenced envpath + $this->assertSame($stackDir . '/docker-compose.yml', $info->composeFilePath); + } + public function testBuildComposeArgsWithQuotedComposeFileInEnv(): void { $stack = 'env-compose-file-quoted'; diff --git a/tests/unit/compose.bats b/tests/unit/compose.bats index eb9ef3dd..5c27c28f 100644 --- a/tests/unit/compose.bats +++ b/tests/unit/compose.bats @@ -150,14 +150,36 @@ test_setup() { assert_success } -@test "compose.sh update action pull step uses --ignore-buildable" { - # The update action pulls before 'up -d --build'; buildable services are handled by --build - run grep -E 'pull --ignore-buildable' "$COMPOSE_SCRIPT" +@test "autostart script detaches from array-start and exits immediately" { + local autostart_script="$BATS_TEST_DIRNAME/../../source/compose.manager/event/docker_started" + run grep -F 'nohup env COMPOSE_MANAGER_AUTOSTART_CHILD=1' "$autostart_script" + assert_success + run grep -F 'COMPOSE_MANAGER_AUTOSTART_CHILD' "$autostart_script" + assert_success + run grep -F 'Autostart event received; detaching background worker' "$autostart_script" + assert_success +} + +@test "compose.sh update action only rebuilds when --build is requested" { + # Issue #149: forcing --build breaks stacks that publish an image alongside + # an unbuildable build: section, so the rebuild must be opt-in. + run grep -F 'up -d --build' "$COMPOSE_SCRIPT" + assert_failure + + run grep -F -- '--build )' "$COMPOSE_SCRIPT" + assert_success + + run grep -F 'build_on_update=true' "$COMPOSE_SCRIPT" + assert_success +} + +@test "compose.sh update action pairs --ignore-buildable with the rebuild flag" { + # --ignore-buildable is only correct when we rebuild those services ourselves. + run grep -F 'pull_args+=("--ignore-buildable")' "$COMPOSE_SCRIPT" + assert_success + + run grep -F 'up_args+=("--build")' "$COMPOSE_SCRIPT" assert_success - # Should appear at least twice (pull action + update action) - local count - count=$(grep -cE 'pull --ignore-buildable' "$COMPOSE_SCRIPT") - [ "$count" -ge 2 ] } @test "compose.sh mutating commands have explicit final exit propagation" {