diff --git a/other/nightly/install.sh b/other/nightly/install.sh index f2bdb5830b..ebd309a021 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -7,7 +7,7 @@ ## ROOT_USER_PASSWORD - Predefined root user password ## DOCKER_ADDRESS_POOL_BASE - Custom Docker address pool base (default: 10.0.0.0/8) ## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) -## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) +## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration, also when Docker must restart with running containers (default: false) ## AUTOUPDATE - Set to "false" to disable auto-updates ## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io) @@ -837,131 +837,39 @@ step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}" step_start "Configuring Docker daemon" "log rotation · address pools" -echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" -echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" - -mkdir -p /etc/docker - -# Backup original daemon.json if it exists -if [ -f /etc/docker/daemon.json ]; then - cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE" -fi - -# Create coolify configuration with or without address pools based on whether they were explicitly provided +# Changing daemon.json needs a Docker restart, which stops every running container. +# Coolify sets log rotation and the network pool only when no pool is configured or DOCKER_POOL_FORCE_OVERRIDE=true, +# keeps all other keys, and does not restart a Docker daemon with running containers unless DOCKER_POOL_FORCE_OVERRIDE=true. if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ] || [ "$EXISTING_POOL_CONFIGURED" = false ]; then - # First check if the configuration would actually change anything - if [ -f /etc/docker/daemon.json ]; then - CURRENT_POOL_BASE=$(jq -r '.["default-address-pools"][0].base' /etc/docker/daemon.json 2>/dev/null) - CURRENT_POOL_SIZE=$(jq -r '.["default-address-pools"][0].size' /etc/docker/daemon.json 2>/dev/null) - - if [ "$CURRENT_POOL_BASE" = "$DOCKER_ADDRESS_POOL_BASE" ] && [ "$CURRENT_POOL_SIZE" = "$DOCKER_ADDRESS_POOL_SIZE" ]; then - echo " - Network pool configuration unchanged, skipping update" - NEED_MERGE=false + mkdir -p /etc/docker + CURRENT_DAEMON_CONFIG='{}' + if [ -s /etc/docker/daemon.json ] && ! CURRENT_DAEMON_CONFIG=$(jq -S . /etc/docker/daemon.json 2>/dev/null); then + CURRENT_DAEMON_CONFIG="" + fi + if [ -z "$CURRENT_DAEMON_CONFIG" ]; then + warn "Docker daemon configuration not changed: /etc/docker/daemon.json is not valid JSON." + else + NEW_DAEMON_CONFIG=$(jq -S --arg base "$DOCKER_ADDRESS_POOL_BASE" --argjson size "$DOCKER_ADDRESS_POOL_SIZE" \ + '. * {"log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3"}, "default-address-pools": [{"base": $base, "size": $size}]}' <<<"$CURRENT_DAEMON_CONFIG") + RUNNING_CONTAINERS=$({ docker ps -q 2>/dev/null || true; } | wc -l) + if [ "$NEW_DAEMON_CONFIG" = "$CURRENT_DAEMON_CONFIG" ]; then + echo " - Configuration is up to date" + elif [ "$RUNNING_CONTAINERS" -gt 0 ] && [ "$DOCKER_POOL_FORCE_OVERRIDE" != true ]; then + warn "Docker daemon configuration not changed: log rotation and the ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE}) network pool need a Docker restart, which stops ${RUNNING_CONTAINERS} running container(s) and can change the docker0 bridge IP. To apply it, run the installer again with DOCKER_POOL_FORCE_OVERRIDE=true." + UI_STEP_DETAIL="skipped · containers running" else - # If force override is enabled or no existing configuration exists, - # create a new configuration with the specified address pools - echo " - Creating new Docker configuration with network pool: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" - cat >/etc/docker/daemon.json </etc/docker/daemon.json </dev/null 2>&1; then - echo " - Log configuration is up to date" - NEED_MERGE=false - else - # Create a configuration without address pools to preserve existing ones - cat >/etc/docker/daemon.json.coolify </etc/docker/daemon.json </etc/docker/daemon.json + echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})" echo " - Configuration updated - restarting Docker daemon..." restart_docker_service - else - echo " - Configuration is up to date" fi - else - echo " - Configuration is up to date" fi else - if [ "$NEED_MERGE" = true ]; then - echo " - Configuration updated - restarting Docker daemon..." - restart_docker_service - else - echo " - Configuration is up to date" - fi + echo " - Using the existing network pool ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})" + echo " - To override it: DOCKER_POOL_FORCE_OVERRIDE=true" fi step_done diff --git a/scripts/install.sh b/scripts/install.sh index adc6f745c9..0c8c2ae6a8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,7 +7,7 @@ ## ROOT_USER_PASSWORD - Predefined root user password ## DOCKER_ADDRESS_POOL_BASE - Custom Docker address pool base (default: 10.0.0.0/8) ## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) -## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) +## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration, also when Docker must restart with running containers (default: false) ## AUTOUPDATE - Set to "false" to disable auto-updates ## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io) @@ -837,131 +837,39 @@ step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}" step_start "Configuring Docker daemon" "log rotation · address pools" -echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" -echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" - -mkdir -p /etc/docker - -# Backup original daemon.json if it exists -if [ -f /etc/docker/daemon.json ]; then - cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE" -fi - -# Create coolify configuration with or without address pools based on whether they were explicitly provided +# Changing daemon.json needs a Docker restart, which stops every running container. +# Coolify sets log rotation and the network pool only when no pool is configured or DOCKER_POOL_FORCE_OVERRIDE=true, +# keeps all other keys, and does not restart a Docker daemon with running containers unless DOCKER_POOL_FORCE_OVERRIDE=true. if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ] || [ "$EXISTING_POOL_CONFIGURED" = false ]; then - # First check if the configuration would actually change anything - if [ -f /etc/docker/daemon.json ]; then - CURRENT_POOL_BASE=$(jq -r '.["default-address-pools"][0].base' /etc/docker/daemon.json 2>/dev/null) - CURRENT_POOL_SIZE=$(jq -r '.["default-address-pools"][0].size' /etc/docker/daemon.json 2>/dev/null) - - if [ "$CURRENT_POOL_BASE" = "$DOCKER_ADDRESS_POOL_BASE" ] && [ "$CURRENT_POOL_SIZE" = "$DOCKER_ADDRESS_POOL_SIZE" ]; then - echo " - Network pool configuration unchanged, skipping update" - NEED_MERGE=false + mkdir -p /etc/docker + CURRENT_DAEMON_CONFIG='{}' + if [ -s /etc/docker/daemon.json ] && ! CURRENT_DAEMON_CONFIG=$(jq -S . /etc/docker/daemon.json 2>/dev/null); then + CURRENT_DAEMON_CONFIG="" + fi + if [ -z "$CURRENT_DAEMON_CONFIG" ]; then + warn "Docker daemon configuration not changed: /etc/docker/daemon.json is not valid JSON." + else + NEW_DAEMON_CONFIG=$(jq -S --arg base "$DOCKER_ADDRESS_POOL_BASE" --argjson size "$DOCKER_ADDRESS_POOL_SIZE" \ + '. * {"log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3"}, "default-address-pools": [{"base": $base, "size": $size}]}' <<<"$CURRENT_DAEMON_CONFIG") + RUNNING_CONTAINERS=$({ docker ps -q 2>/dev/null || true; } | wc -l) + if [ "$NEW_DAEMON_CONFIG" = "$CURRENT_DAEMON_CONFIG" ]; then + echo " - Configuration is up to date" + elif [ "$RUNNING_CONTAINERS" -gt 0 ] && [ "$DOCKER_POOL_FORCE_OVERRIDE" != true ]; then + warn "Docker daemon configuration not changed: log rotation and the ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE}) network pool need a Docker restart, which stops ${RUNNING_CONTAINERS} running container(s) and can change the docker0 bridge IP. To apply it, run the installer again with DOCKER_POOL_FORCE_OVERRIDE=true." + UI_STEP_DETAIL="skipped · containers running" else - # If force override is enabled or no existing configuration exists, - # create a new configuration with the specified address pools - echo " - Creating new Docker configuration with network pool: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" - cat >/etc/docker/daemon.json </etc/docker/daemon.json </dev/null 2>&1; then - echo " - Log configuration is up to date" - NEED_MERGE=false - else - # Create a configuration without address pools to preserve existing ones - cat >/etc/docker/daemon.json.coolify </etc/docker/daemon.json </etc/docker/daemon.json + echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})" echo " - Configuration updated - restarting Docker daemon..." restart_docker_service - else - echo " - Configuration is up to date" fi - else - echo " - Configuration is up to date" fi else - if [ "$NEED_MERGE" = true ]; then - echo " - Configuration updated - restarting Docker daemon..." - restart_docker_service - else - echo " - Configuration is up to date" - fi + echo " - Using the existing network pool ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})" + echo " - To override it: DOCKER_POOL_FORCE_OVERRIDE=true" fi step_done diff --git a/tests/Unit/InstallScriptDockerDaemonConfigTest.php b/tests/Unit/InstallScriptDockerDaemonConfigTest.php new file mode 100644 index 0000000000..2dab67a70d --- /dev/null +++ b/tests/Unit/InstallScriptDockerDaemonConfigTest.php @@ -0,0 +1,118 @@ + $variables + * @return array{config: ?array, restarted: bool, output: string} + */ +function runInstallScriptDockerDaemonStep(string $scriptPath, ?string $daemonJson, int $runningContainers, array $variables = []): array +{ + $script = file_get_contents(dirname(__DIR__, 2).'/'.$scriptPath); + preg_match('/^step_start "Configuring Docker daemon".*?^step_done$/ms', $script, $matches); + expect($matches)->not->toBeEmpty(); + + $etcDocker = sys_get_temp_dir().'/coolify-install-docker-'.bin2hex(random_bytes(6)); + mkdir($etcDocker); + if ($daemonJson !== null) { + file_put_contents("{$etcDocker}/daemon.json", $daemonJson); + } + + $variables = [ + 'DATE' => '20260101-000000', + 'DOCKER_ADDRESS_POOL_BASE' => '10.0.0.0/8', + 'DOCKER_ADDRESS_POOL_SIZE' => '24', + 'DOCKER_POOL_FORCE_OVERRIDE' => 'false', + 'EXISTING_POOL_CONFIGURED' => 'false', + ...$variables, + ]; + $prelude = implode("\n", array_map(fn ($name, $value) => "{$name}=".escapeshellarg($value), array_keys($variables), $variables)); + $stubs = << ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + $output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $config = file_exists("{$etcDocker}/daemon.json") ? json_decode(file_get_contents("{$etcDocker}/daemon.json"), true) : null; + exec('rm -rf '.escapeshellarg($etcDocker)); + + expect($exitCode)->toBe(0, $output); + + return ['config' => $config, 'restarted' => str_contains($output, 'RESTARTED'), 'output' => $output]; +} + +$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh']; + +it('does not create daemon.json or restart Docker when containers are running', function (string $scriptPath) { + $result = runInstallScriptDockerDaemonStep($scriptPath, null, runningContainers: 30); + + expect($result['config'])->toBeNull() + ->and($result['restarted'])->toBeFalse() + ->and($result['output'])->toContain('stops 30 running container(s)') + ->toContain('DOCKER_POOL_FORCE_OVERRIDE=true'); +})->with($installScripts); + +it('configures the network pool and restarts Docker when no containers are running', function (string $scriptPath) { + $result = runInstallScriptDockerDaemonStep($scriptPath, null, runningContainers: 0); + + expect($result['config'])->toEqual([ + 'default-address-pools' => [['base' => '10.0.0.0/8', 'size' => 24]], + 'log-driver' => 'json-file', + 'log-opts' => ['max-file' => '3', 'max-size' => '10m'], + ])->and($result['restarted'])->toBeTrue(); +})->with($installScripts); + +it('keeps other daemon.json settings when it adds the network pool', function (string $scriptPath) { + $result = runInstallScriptDockerDaemonStep($scriptPath, '{"registry-mirrors":["https://mirror.example.com"],"log-opts":{"compress":"true"}}', runningContainers: 0); + + expect($result['config'])->toMatchArray([ + 'registry-mirrors' => ['https://mirror.example.com'], + 'default-address-pools' => [['base' => '10.0.0.0/8', 'size' => 24]], + 'log-opts' => ['compress' => 'true', 'max-file' => '3', 'max-size' => '10m'], + ])->and($result['restarted'])->toBeTrue(); +})->with($installScripts); + +it('applies the network pool with running containers when DOCKER_POOL_FORCE_OVERRIDE is true', function (string $scriptPath) { + $result = runInstallScriptDockerDaemonStep($scriptPath, '{"default-address-pools":[{"base":"172.16.0.0/12","size":20}]}', runningContainers: 5, variables: [ + 'DOCKER_POOL_FORCE_OVERRIDE' => 'true', + 'EXISTING_POOL_CONFIGURED' => 'true', + ]); + + expect($result['config']['default-address-pools'])->toBe([['base' => '10.0.0.0/8', 'size' => 24]]) + ->and($result['restarted'])->toBeTrue(); +})->with($installScripts); + +it('does not restart Docker when the configuration is already up to date', function (string $scriptPath) { + $daemonJson = '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"},"default-address-pools":[{"base":"10.0.0.0/8","size":24}]}'; + $result = runInstallScriptDockerDaemonStep($scriptPath, $daemonJson, runningContainers: 0, variables: ['DOCKER_POOL_FORCE_OVERRIDE' => 'true']); + + expect($result['restarted'])->toBeFalse() + ->and($result['output'])->toContain('Configuration is up to date'); +})->with($installScripts); + +it('does not change an existing network pool without DOCKER_POOL_FORCE_OVERRIDE', function (string $scriptPath) { + $daemonJson = '{"default-address-pools":[{"base":"172.16.0.0/12","size":20}]}'; + $result = runInstallScriptDockerDaemonStep($scriptPath, $daemonJson, runningContainers: 0, variables: [ + 'DOCKER_ADDRESS_POOL_BASE' => '172.16.0.0/12', + 'DOCKER_ADDRESS_POOL_SIZE' => '20', + 'EXISTING_POOL_CONFIGURED' => 'true', + ]); + + expect($result['config'])->toBe(json_decode($daemonJson, true)) + ->and($result['restarted'])->toBeFalse(); +})->with($installScripts);