diff --git a/app/Actions/Application/CleanupCancelledDeployment.php b/app/Actions/Application/CleanupCancelledDeployment.php index 171e660e66..5da94db3cb 100644 --- a/app/Actions/Application/CleanupCancelledDeployment.php +++ b/app/Actions/Application/CleanupCancelledDeployment.php @@ -28,6 +28,12 @@ class CleanupCancelledDeployment if (str($containerExists)->trim()->isNotEmpty()) { instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); $containerStopped = true; + + // A removed helper cannot stop the Railpack builder, which keeps the memory of its builds. + if ($deployment->application?->build_pack === 'railpack') { + $helperImage = coolifyHelperImage().':'.getHelperVersion(); + instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false); + } } } catch (\Throwable $e) { $firstError ??= $e; diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 201f86b367..3c0e2ba7e0 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique $helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; - $buildxMetadataVolume = isDev() && $server->isLocalhost() - ? 'coolify-buildx' - : '$HOME/.docker/buildx'; $cleanupLog = []; @@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', - "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true", + // The prune fails on a stopped builder, so start it first. + railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'), "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", ]; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 07f77318a4..c822d35866 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3350,12 +3350,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $variables; } + /** + * Without the `...` entry, the list replaces the packages that Railpack generates + * (for example libatomic1, which Node.js 25+ needs at runtime). + */ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection { $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) ->filter() ->values(); + if (! $packages->contains('...')) { + $packages->prepend('...'); + } + foreach (['curl', 'wget'] as $package) { if (! $packages->contains($package)) { $packages->push($package); @@ -3439,7 +3447,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ." {$this->workdir}"; return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand); + ."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')'); } private function decode_railpack_config(string $config, string $source): array diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 059f55e333..2c1025b623 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -2109,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString): return $modifiedCommand ?? $command; } + +/** + * Run a command that uses the Railpack BuildKit builder, then stop the builder when no other + * process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts. + * + * The lock file is in the buildx metadata directory, which all helper containers of a server share. + * The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it. + */ +function railpackBuilderLockedScript(string $command): string +{ + return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; ' + ."{$command}; status=\$?; " + .'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; ' + .'exit $status'; +} + +/** + * The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses. + */ +function railpackBuildxMetadataVolume(Server $server): string +{ + return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx'; +} + +/** + * Run a command for the Railpack builder from a temporary helper container, then stop the builder + * when no build uses it. + */ +function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string +{ + $script = railpackBuilderLockedScript($command); + + return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true"; +} diff --git a/config/constants.php b/config/constants.php index e792499c35..657d393ab6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.4.2', - 'helper_version' => '1.0.17', - 'railpack_version' => '0.23.0', + 'helper_version' => '1.0.18', + 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1fe99f01c2..b3c068f437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.23.0 -# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) -ARG MISE_VERSION=2026.3.17 +ARG RAILPACK_VERSION=0.40.1 +# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt) +ARG MISE_VERSION=2026.9.15 FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client FROM ${BASE_IMAGE} AS base @@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \ RUN mkdir -p ~/.docker/cli-plugins # Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION). -# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary. +# Railpack skips its own mise download when this file exists. RUN mkdir -p /tmp/railpack/mise && \ if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \ diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a1f7ec3a90..3f4323c7ea 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2" diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 076bbae527..6706bcda87 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1372,7 +1372,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); @@ -1424,7 +1424,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 6912ae604a..0721fbc4d2 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -179,3 +179,16 @@ test('cancelling after switching to another team still cleans up and starts the assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); }); + +test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) { + $this->application->update(['build_pack' => $buildPack]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder); +})->with([ + 'railpack' => ['railpack', true], + 'nixpacks' => ['nixpacks', false], +]); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 168e8be881..0ba3605888 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -260,6 +260,23 @@ it('pins docker config while running railpack buildx commands', function () { ->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack'); }); +it('stops the railpack builder after the build when no other build uses it', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod($job, $reflection, 'railpack_build_command', ['coollabsio/coolify:test', collect([])]); + + $sharedLock = strpos($command, 'flock -s 9'); + $build = strpos($command, 'docker buildx build --builder coolify-railpack'); + $stop = strpos($command, 'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack'); + + expect($sharedLock)->not->toBeFalse() + ->and($build)->toBeGreaterThan($sharedLock) + ->and($stop)->toBeGreaterThan($build) + ->and($command)->toEndWith('exit $status'); +}); + it('filters reserved docker client variables from railpack build secrets', function () { [$job, $reflection] = makeRailpackDeploymentJob([ 'uuid' => 'application-uuid', diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php index 1dda8b8c3e..0b7d5406c9 100644 --- a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -81,13 +81,13 @@ it('generates escaped railpack env args from resolved values and includes instal 'RAILPACK_CUSTOM_FLAG' => 'hello world', 'RAILPACK_JSON' => '{"token":"abc"}', 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('RAILPACK_NULL'); }); @@ -136,7 +136,7 @@ it('uses preview railpack environment variables for preview deployments', functi expect($variables->all())->toBe([ 'RAILPACK_PREVIEW_ONLY' => 'preview-value', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); }); @@ -195,7 +195,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($variables->all())->toBe([ 'MY_BUILD_VAR' => 'hello', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', 'COOLIFY_URL' => 'https://app.example.com', 'COOLIFY_FQDN' => 'app.example.com', 'COOLIFY_BRANCH' => 'main', @@ -209,7 +209,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('EMPTY_VAR'); expect($envArgs)->not->toContain('NULL_VAR'); }); @@ -257,11 +257,11 @@ it('preserves user railpack deploy apt packages while adding healthcheck tools o $method->setAccessible(true); $variables = $method->invoke($job); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... ffmpeg curl wget'); $envArgsProperty = $reflection->getProperty('env_railpack_args'); $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... ffmpeg curl wget'"); }); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index 594ecbd046..a0ef50d1ad 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -863,3 +863,17 @@ test('parseCommandsByLineForSudo keeps pipes inside quoted strings unchanged', f expect($result[0])->toBe("sudo docker ps --format '{{.Names}} | {{.Image}}' | sudo grep app"); }); + +test('railpack builder helper command gets sudo only outside the helper script', function () { + $command = railpackBuilderHelperCommand('$HOME/.docker/buildx', 'coolify-helper:1.0.0', 'docker buildx prune --builder coolify-railpack -af'); + + $parsed = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; + + // The SSH user's shell must expand $HOME, so the line must not be wrapped in `sudo bash -c`. + expect($parsed)->toBe( + 'sudo docker run --rm -v $HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock coolify-helper:1.0.0' + ." bash -c 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; docker buildx prune --builder coolify-railpack -af; status=$?;" + .' if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi;' + ." exit \$status' 2>/dev/null || sudo true" + ); +}); diff --git a/tests/Unit/RailpackBuilderLockTest.php b/tests/Unit/RailpackBuilderLockTest.php new file mode 100644 index 0000000000..273104d6c4 --- /dev/null +++ b/tests/Unit/RailpackBuilderLockTest.php @@ -0,0 +1,43 @@ +run(); + + return $process; + } finally { + @unlink($directory.'/builder.lock'); + rmdir($directory); + } +} + +test('a failed shared lock prevents the builder command from running', function () { + $process = runRailpackLockScript('printf build-ran', 'flock() { return 73; }; '); + + expect($process->getExitCode())->toBe(73); + expect($process->getOutput())->toBe(''); +}); + +test('the builder stops after a failed command without hiding its exit status', function () { + $process = runRailpackLockScript('(exit 42)', 'exec 3>&1; docker() { printf stopped >&3; return 0; }; '); + + expect($process->getExitCode())->toBe(42); + expect($process->getOutput())->toBe('stopped'); +}); + +test('a held shared lock prevents stopping the builder', function () { + $process = runRailpackLockScript('printf built', 'exec 8>/root/.docker/buildx/coolify-railpack.lock; flock -s 8; docker() { exit 99; }; '); + + expect($process->getExitCode())->toBe(0); + expect($process->getOutput())->toBe('built'); +}); diff --git a/versions.json b/versions.json index a1f7ec3a90..3f4323c7ea 100644 --- a/versions.json +++ b/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2"