diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b..626f6d7b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ # Development Guides ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 40705617f..829fbda80 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -212,7 +212,7 @@ public function buildHelperImage() return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40..c3bd4a223 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4152,11 +4152,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeeb..94330bbce 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/scripts/dev-helper b/scripts/dev-helper new file mode 100755 index 000000000..1e1bfae27 --- /dev/null +++ b/scripts/dev-helper @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper} +DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)" +COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify} + +usage() { + cat < [tag] + +Commands: + build [tag] Build the local helper image + use [tag] Configure local Coolify to use an existing helper image + verify [tag] Verify tools and run a Docker Compose socket smoke test + deploy [tag] Deploy seeded Dockerfile, Compose, and Nixpacks applications + reset Clear the local helper version override + test [tag] Build, select, verify, and run real seeded deployments + +Default tag: $DEFAULT_TAG +EOF +} + +require_local_coolify() { + local is_dev + + if ! docker inspect "$COOLIFY_CONTAINER" >/dev/null 2>&1; then + echo "Coolify container '$COOLIFY_CONTAINER' is not running." >&2 + exit 1 + fi + + is_dev=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo isDev() ? "yes" : "no";' 2>/dev/null | tail -1) + if [[ $is_dev != yes ]]; then + echo "The running Coolify instance is not in development mode." >&2 + exit 1 + fi +} + +validate_tag() { + local tag=$1 + + if [[ ! $tag =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "Invalid Docker tag: $tag" >&2 + exit 1 + fi +} + +image_for() { + echo "${REPOSITORY}:$1" +} + +build_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + echo "Building $image" + docker build --progress=plain -f "$ROOT_DIR/docker/coolify-helper/Dockerfile" -t "$image" "$ROOT_DIR" +} + +use_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + require_local_coolify + docker image inspect "$image" >/dev/null + docker exec -e DEV_HELPER_VERSION="$tag" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => getenv("DEV_HELPER_VERSION"), + ]); + ' >/dev/null + + local selected + selected=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo getHelperVersion();' 2>/dev/null | tail -1) + if [[ $selected != "$tag" ]]; then + echo "Coolify selected helper '$selected' instead of '$tag'." >&2 + exit 1 + fi + + echo "Coolify now uses $image" +} + +verify_helper() { + local tag=$1 + local image project compose + image=$(image_for "$tag") + project="coolify-helper-${tag//[^A-Za-z0-9_-]/-}-smoke" + compose=$'services:\n app:\n image: alpine:3.21\n command: ["sh", "-c", "sleep 30"]\n' + + docker image inspect "$image" >/dev/null + + docker run --rm "$image" docker --version + docker run --rm "$image" docker compose version + docker run --rm "$image" docker buildx version + docker run --rm "$image" pack version + docker run --rm "$image" nixpacks --version + docker run --rm "$image" railpack --version + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Client.APIVersion}}/{{.Server.APIVersion}}' + + cleanup_compose() { + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - down --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup_compose RETURN + + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - up -d --wait + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - ps --format json + cleanup_compose + trap - RETURN + + if [[ -n $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]]; then + echo "Compose smoke-test resources were not removed." >&2 + exit 1 + fi + + echo "Helper verification passed for $image" +} + +queue_deployment() { + local application_uuid=$1 + + docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $application = App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->firstOrFail(); + $deploymentUuid = (string) Illuminate\Support\Str::uuid(); + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + force_rebuild: true, + no_questions_asked: true, + ); + echo "DEPLOYMENT_UUID={$deploymentUuid}"; + ' 2>/dev/null | sed -n 's/^DEPLOYMENT_UUID=//p' | tail -1 +} + +wait_for_deployment() { + local deployment_uuid=$1 + local expected_image=$2 + local status + + for _ in $(seq 1 150); do + status=$(docker exec -e DEPLOYMENT_UUID="$deployment_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->first(); + echo $deployment?->status ?? "missing"; + ' 2>/dev/null | tail -1) + + case "$status" in + finished) + break + ;; + failed|cancelled|missing) + echo "Deployment $deployment_uuid ended with status: $status" >&2 + return 1 + ;; + esac + + sleep 4 + done + + if [[ $status != finished ]]; then + echo "Deployment $deployment_uuid timed out with status: $status" >&2 + return 1 + fi + + local used_expected_helper + used_expected_helper=$(docker exec \ + -e DEPLOYMENT_UUID="$deployment_uuid" \ + -e EXPECTED_HELPER_IMAGE="$expected_image" \ + "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->firstOrFail(); + $logs = collect(json_decode($deployment->logs, true)) + ->pluck("output") + ->implode("\n"); + echo str_contains( + $logs, + "Preparing container with helper image: ".getenv("EXPECTED_HELPER_IMAGE"), + ) ? "yes" : "no"; + ' 2>/dev/null | tail -1) + + if [[ $used_expected_helper != yes ]]; then + echo "Deployment $deployment_uuid did not use $expected_image." >&2 + return 1 + fi +} + +deploy_examples() { + local tag=$1 + local image application_uuid deployment_uuid fqdn + image=$(image_for "$tag") + + require_local_coolify + use_helper "$tag" + + for application_uuid in dockerfile docker-compose nodejs; do + echo "Deploying seeded application: $application_uuid" + deployment_uuid=$(queue_deployment "$application_uuid") + if [[ -z $deployment_uuid ]]; then + echo "Could not queue $application_uuid." >&2 + exit 1 + fi + + wait_for_deployment "$deployment_uuid" "$image" + + fqdn=$(docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + echo App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->value("fqdn") ?? ""; + ' 2>/dev/null | tail -1) + + if [[ -n $fqdn ]]; then + curl --fail --silent --show-error --output /dev/null "$fqdn" + fi + + echo "Deployment passed: $application_uuid ($deployment_uuid)" + done +} + +reset_helper() { + require_local_coolify + docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => null, + ]); + ' >/dev/null + echo "Development helper override cleared." +} + +command=${1:-help} +tag=${2:-$DEFAULT_TAG} + +case "$command" in + build) + validate_tag "$tag" + build_helper "$tag" + ;; + use) + validate_tag "$tag" + use_helper "$tag" + ;; + verify) + validate_tag "$tag" + verify_helper "$tag" + ;; + deploy) + validate_tag "$tag" + deploy_examples "$tag" + ;; + reset) + reset_helper + ;; + test) + validate_tag "$tag" + build_helper "$tag" + use_helper "$tag" + verify_helper "$tag" + deploy_examples "$tag" + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php index 03316598c..0a9114185 100644 --- a/tests/Feature/DevHelperVersionValidationTest.php +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -88,3 +88,22 @@ ->call('buildHelperImage') ->assertDispatched('error'); }); + +test('development helper version is read fresh for queue workers', function () { + config(['app.env' => 'local']); + + InstanceSettings::findOrFail(0)->update(['dev_helper_version' => 'first']); + expect(getHelperVersion())->toBe('first'); + + InstanceSettings::query()->whereKey(0)->update(['dev_helper_version' => 'second']); + + expect(getHelperVersion())->toBe('second'); +}); + +test('development helper build uses the configured helper repository', function () { + $component = file_get_contents(app_path('Livewire/Settings/Index.php')); + + expect($component) + ->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");') + ->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"'); +}); diff --git a/tests/Unit/DevHelperScriptTest.php b/tests/Unit/DevHelperScriptTest.php new file mode 100644 index 000000000..eee180382 --- /dev/null +++ b/tests/Unit/DevHelperScriptTest.php @@ -0,0 +1,19 @@ +toBeFile() + ->and(is_executable($script))->toBeTrue(); + + exec('bash -n '.escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0) + ->and(file_get_contents($script)) + ->toContain('build)') + ->toContain('use)') + ->toContain('verify)') + ->toContain('deploy)') + ->toContain('reset)') + ->toContain('test)'); +}); diff --git a/tests/Unit/DockerCliPackagingTest.php b/tests/Unit/DockerCliPackagingTest.php new file mode 100644 index 000000000..71fa7078e --- /dev/null +++ b/tests/Unit/DockerCliPackagingTest.php @@ -0,0 +1,12 @@ +toContain('ARG DOCKER_VERSION=29.7.2') + ->toContain('ARG DOCKER_COMPOSE_VERSION=5.5.0') + ->toContain('ARG DOCKER_BUILDX_VERSION=0.36.1') + ->toContain('https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz') + ->toContain('https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz') + ->toMatch('/chmod \+x [^\n]*\/usr\/bin\/docker/'); +});