From 793077d74fdbadc3c7388f13cfc6e4135dc96bf4 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 17:12:02 +0000 Subject: [PATCH 001/100] feat(buildpack): add Railpack as a build pack option --- app/Enums/BuildPackTypes.php | 1 + .../Api/ApplicationsController.php | 10 +- app/Jobs/ApplicationDeploymentJob.php | 176 +++++++++++++++++- app/Livewire/Project/Application/General.php | 2 +- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- app/Models/Application.php | 24 ++- docker/coolify-helper/Dockerfile | 20 ++ openapi.json | 6 + openapi.yaml | 11 +- .../project/application/general.blade.php | 11 +- ...ub-private-repository-deploy-key.blade.php | 1 + .../new/github-private-repository.blade.php | 1 + .../new/public-git-repository.blade.php | 1 + .../ApplicationBuildpackCleanupTest.php | 46 +++++ tests/Feature/ApplicationRailpackTest.php | 168 +++++++++++++++++ tests/Feature/BuildpackSwitchCleanupTest.php | 23 +++ 18 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/ApplicationRailpackTest.php diff --git a/app/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php index cb51db6d6..eee898823 100644 --- a/app/Enums/BuildPackTypes.php +++ b/app/Enums/BuildPackTypes.php @@ -8,4 +8,5 @@ enum BuildPackTypes: string case STATIC = 'static'; case DOCKERFILE = 'dockerfile'; case DOCKERCOMPOSE = 'dockercompose'; + case RAILPACK = 'railpack'; } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3444f9f14..4abf1c6e0 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -150,7 +150,7 @@ public function applications(Request $request) 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -318,7 +318,7 @@ public function create_public_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -483,7 +483,7 @@ public function create_private_gh_app_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -644,7 +644,7 @@ public function create_private_deploy_key_application(Request $request) 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -2318,7 +2318,7 @@ public function delete_by_uuid(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e30af5cc7..33905ce59 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -121,6 +121,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_nixpacks_args; + private $env_railpack_args; + private $docker_compose; private $docker_compose_base64; @@ -476,8 +478,12 @@ private function decide_what_to_do() $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { $this->deploy_static_buildpack(); - } else { + } elseif ($this->application->build_pack === 'nixpacks') { $this->deploy_nixpacks_buildpack(); + } elseif ($this->application->build_pack === 'railpack') { + $this->deploy_railpack_buildpack(); + } else { + throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -921,6 +927,37 @@ private function deploy_nixpacks_buildpack() $this->rolling_update(); } + private function deploy_railpack_buildpack() + { + if ($this->use_build_server) { + $this->server = $this->build_server; + } + $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); + $this->prepare_builder_image(); + $this->check_git_if_build_needed(); + $this->generate_image_names(); + if (! $this->force_rebuild) { + $this->check_image_locally_or_remotely(); + if ($this->should_skip_build()) { + return; + } + } + $this->clone_repository(); + $this->cleanup_git(); + $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + + $this->generate_build_env_variables(); + $this->build_railpack_image(); + + // Save runtime environment variables AFTER the build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); + $this->rolling_update(); + } + private function deploy_static_buildpack() { if ($this->use_build_server) { @@ -1943,7 +1980,11 @@ private function deploy_pull_request() if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } - $this->build_image(); + if ($this->application->build_pack === 'railpack') { + $this->build_railpack_image(); + } else { + $this->build_image(); + } // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); @@ -2376,6 +2417,137 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } + private function generate_railpack_env_variables(): void + { + $this->env_railpack_args = collect([]); + if ($this->pull_request_id === 0) { + foreach ($this->application->railpack_environment_variables as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } else { + foreach ($this->application->railpack_environment_variables_preview as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } + + // Note: COOLIFY_* vars are NOT passed to railpack prepare because railpack treats + // all --env vars as secrets that must be provided during docker buildx build. + // COOLIFY_* vars are informational and available at runtime via .env file. + + $this->env_railpack_args = $this->env_railpack_args->implode(' '); + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = 'railpack prepare'; + + if ($this->env_railpack_args) { + $prepare_command .= " {$this->env_railpack_args}"; + } + if ($this->application->build_command) { + $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + } + if ($this->application->start_command) { + $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + } + if ($this->application->install_command) { + $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + } + + $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], + ); + + // Step 2: Build image using docker buildx with railpack frontend. + // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder. + $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.'); + $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); + + $image_name = $this->application->settings->is_static + ? $this->build_image_name + : $this->production_image_name; + + if ($this->application->settings->is_static && $this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + + $cache_args = ''; + if ($this->force_rebuild) { + $cache_args = '--no-cache'; + } else { + $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; + } + + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + .' && docker buildx build --builder coolify-railpack' + ." {$this->addHosts} --network host" + ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" + ." {$cache_args}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$image_name}" + ." {$this->workdir}"; + + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ] + ); + + // Step 3: If static, copy built assets into nginx image + if ($this->application->settings->is_static) { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} +WORKDIR /usr/share/nginx/html/ +LABEL coolify.deploymentId={$this->deployment_uuid} +COPY --from={$this->build_image_name} /app{$publishDir} . +COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); + } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); + } + } + private function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index ca1daef72..bc24c3944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -598,7 +598,7 @@ public function updatedBuildPack() // Sync property to model before checking/modifying $this->syncData(toModel: true); - if ($this->buildPack !== 'nixpacks') { + if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') { $this->isStatic = false; $this->application->settings->is_static = false; $this->application->settings->save(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 61ae0e151..c208e2cd2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -62,7 +62,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -82,7 +82,7 @@ public function updatedSelectedRepositoryId(): void public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index e46ad7d78..f312a9dc0 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -45,7 +45,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -95,7 +95,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3df31a6a3..eb4ce7b84 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -57,7 +57,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -99,7 +99,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..b97201faa 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -39,7 +39,7 @@ 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], - 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']], + 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], 'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'], 'install_command' => ['type' => 'string', 'description' => 'Install command.'], 'build_command' => ['type' => 'string', 'description' => 'Build command.'], @@ -854,7 +854,8 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables() @@ -864,6 +865,13 @@ public function nixpacks_environment_variables() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', false) + ->where('key', 'like', 'RAILPACK_%'); + } + public function environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -882,7 +890,8 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables_preview() @@ -892,6 +901,13 @@ public function nixpacks_environment_variables_preview() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables_preview() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', true) + ->where('key', 'like', 'RAILPACK_%'); + } + public function scheduled_tasks(): HasMany { return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); @@ -1011,7 +1027,7 @@ public function deploymentType() public function could_set_build_commands(): bool { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { return true; } diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 14879eb96..ebe667437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 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.21.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.12 # https://github.com/minio/mc/releases ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z @@ -25,17 +29,32 @@ ARG DOCKER_COMPOSE_VERSION ARG DOCKER_BUILDX_VERSION ARG PACK_VERSION ARG NIXPACKS_VERSION +ARG RAILPACK_VERSION +ARG MISE_VERSION USER root WORKDIR /artifacts RUN apk add --no-cache bash curl git git-lfs openssh-client tar tini 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. +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 && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ + curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + fi + RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \ curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \ (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi @@ -45,6 +64,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ (curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi diff --git a/openapi.json b/openapi.json index d119176a1..a23a9df40 100644 --- a/openapi.json +++ b/openapi.json @@ -111,6 +111,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -564,6 +565,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1009,6 +1011,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1434,6 +1437,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -2442,6 +2446,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -11509,6 +11514,7 @@ "description": "Build pack.", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" diff --git a/openapi.yaml b/openapi.yaml index 7064be28a..e3168d131 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -81,7 +81,7 @@ paths: description: 'The git branch.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -371,7 +371,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -655,7 +655,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -923,7 +923,7 @@ paths: description: 'The Dockerfile content.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -1556,7 +1556,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -7256,6 +7256,7 @@ components: description: 'Build pack.' enum: - nixpacks + - railpack - static - dockerfile - dockercompose diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e27eda8b6..639be8f5d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,6 +31,7 @@
+ @@ -218,16 +219,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" /> @else @if ($application->could_set_build_commands()) - @if ($buildPack === 'nixpacks') + @if ($buildPack === 'nixpacks' || $buildPack === 'railpack')
- - -
-
Nixpacks will detect the required configuration +
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration automatically. Framework Specific Docs diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 9eb9baea8..3c3313643 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,6 +51,7 @@ class="loading loading-xs dark:text-warning loading-spinner">
+ diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 129c508a9..2d68b1900 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -77,6 +77,7 @@ @endforeach + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 02489719a..03fc71a5d 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,6 +41,7 @@ helper="You can select other branches after configuration is done." /> @endif + diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index b6b535a76..857410920 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -117,6 +117,52 @@ expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); + test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + + test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->docker_compose_domains)->toBeNull(); + expect($application->docker_compose_raw)->toBeNull(); + }); + test('model does not clear dockerfile fields when switching to dockerfile', function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php new file mode 100644 index 000000000..f3e49cc21 --- /dev/null +++ b/tests/Feature/ApplicationRailpackTest.php @@ -0,0 +1,168 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns true for nixpacks', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'nixpacks', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns false for dockerfile', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ]); + + expect($application->could_set_build_commands())->toBeFalse(); + }); + + test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + $railpackVars = $application->railpack_environment_variables; + expect($railpackVars)->toHaveCount(1); + expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION'); + }); + + test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + $runtimeVars = $application->runtime_environment_variables; + expect($runtimeVars)->toHaveCount(1); + expect($runtimeVars->first()->key)->toBe('APP_ENV'); + }); + + test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_preview' => true, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => true, + ]); + + $previewVars = $application->railpack_environment_variables_preview; + expect($previewVars)->toHaveCount(1); + expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD'); + }); +}); diff --git a/tests/Feature/BuildpackSwitchCleanupTest.php b/tests/Feature/BuildpackSwitchCleanupTest.php index b040f9a8f..babd940cb 100644 --- a/tests/Feature/BuildpackSwitchCleanupTest.php +++ b/tests/Feature/BuildpackSwitchCleanupTest.php @@ -111,6 +111,29 @@ expect($application->dockerfile)->toBeNull(); }); + test('clears dockerfile fields when switching from dockerfile to railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('buildPack', 'railpack') + ->call('updatedBuildPack'); + + $application->refresh(); + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + test('clears dockerfile fields when switching from dockerfile to dockercompose', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, From cddbaf581fd319d9266f31a97194c04ea166fe1e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 19:02:10 +0000 Subject: [PATCH 002/100] refactor(railpack): extract static image build, fix port logic, bump to v0.22.0 Extract build_railpack_static_image() into its own method, prevent port override when is_static is set, bump Railpack to 0.22.0, and improve test setup with beforeEach and correct polymorphic env var fields. --- app/Jobs/ApplicationDeploymentJob.php | 53 ++++++++++--------- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- docker/coolify-helper/Dockerfile | 2 +- .../ApplicationBuildpackCleanupTest.php | 50 ++++++++++++++--- tests/Feature/ApplicationRailpackTest.php | 38 ++++--------- 7 files changed, 93 insertions(+), 62 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 33905ce59..460a7bf3d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -483,7 +483,7 @@ private function decide_what_to_do() } elseif ($this->application->build_pack === 'railpack') { $this->deploy_railpack_buildpack(); } else { - throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); + throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -2517,35 +2517,40 @@ private function build_railpack_image(): void // Step 3: If static, copy built assets into nginx image if ($this->application->settings->is_static) { - $publishDir = trim($this->application->publish_directory, '/'); - $publishDir = $publishDir ? "/{$publishDir}" : ''; - $dockerfile = base64_encode("FROM {$this->application->static_image} + $this->build_railpack_static_image(); + } + } + + private function build_railpack_static_image(): void + { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from={$this->build_image_name} /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - $nginx_config = $this->application->settings->is_spa - ? base64_encode(defaultNginxConfiguration('spa')) - : base64_encode(defaultNginxConfiguration()); - } - - $static_build = $this->dockerBuildkitSupported - ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" - : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; - - $base64_static_build = base64_encode($static_build); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - ); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); } private function generate_coolify_env_variables(bool $forBuildTime = false): Collection diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index c208e2cd2..63240620b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -84,7 +84,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f312a9dc0..92d388234 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -97,7 +97,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index eb4ce7b84..dd7a682d1 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -101,7 +101,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->isStatic) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->isStatic = false; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index ebe667437..1f4ca8788 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,7 +12,7 @@ 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.21.0 +ARG RAILPACK_VERSION=0.22.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.12 # https://github.com/minio/mc/releases diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index 857410920..0dc0a8303 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -78,26 +78,29 @@ // Add environment variables that should be deleted EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'REGULAR_VAR', 'value' => 'should_remain', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); @@ -154,6 +157,34 @@ 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', ]); + // Add environment variables that should be deleted + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_FQDN_APP', + 'value' => 'app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_URL_APP', + 'value' => 'http://app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'should_remain', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + $application->build_pack = 'railpack'; $application->save(); $application->refresh(); @@ -161,6 +192,13 @@ expect($application->build_pack)->toBe('railpack'); expect($application->docker_compose_domains)->toBeNull(); expect($application->docker_compose_raw)->toBeNull(); + + // Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted + expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + + // Verify regular variables remain + expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); test('model does not clear dockerfile fields when switching to dockerfile', function () { diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php index f3e49cc21..59e8a82e0 100644 --- a/tests/Feature/ApplicationRailpackTest.php +++ b/tests/Feature/ApplicationRailpackTest.php @@ -10,13 +10,15 @@ uses(RefreshDatabase::class); describe('Application Railpack Support', function () { - test('could_set_build_commands returns true for railpack', function () { + beforeEach(function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + }); + test('could_set_build_commands returns true for railpack', function () { $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -24,12 +26,8 @@ }); test('could_set_build_commands returns true for nixpacks', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'nixpacks', ]); @@ -37,12 +35,8 @@ }); test('could_set_build_commands returns false for dockerfile', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'dockerfile', ]); @@ -50,12 +44,8 @@ }); test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -92,12 +82,8 @@ }); test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -134,12 +120,8 @@ }); test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); From ff0de0bc31c80245d310c9d39bd6caa653e00914 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:29 +0100 Subject: [PATCH 003/100] fix(docker): add docker buildx prune for coolify-railpack builder --- app/Actions/Server/CleanupDocker.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..135623b1f 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', $imagePruneCmd, 'docker builder prune -af', + 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | 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", From 7dd648e549aa9e86299cbd32dc13e26b17a9dfd9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:45 +0100 Subject: [PATCH 004/100] feat(seeders): add railpack-static example application seed data Add ApplicationSeeder entry for railpack-static example with railpack build pack and corresponding application settings configuration. --- database/seeders/ApplicationSeeder.php | 16 ++++++++++++++++ database/seeders/ApplicationSettingsSeeder.php | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2a0273e0f..edb28c377 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -145,5 +145,21 @@ public function run(): void 'source_id' => 1, 'source_type' => GitlabApp::class, ]); + Application::create([ + 'uuid' => 'railpack-static', + 'name' => 'Railpack Static Example', + 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/static', + 'build_pack' => 'railpack', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 87236df8a..e8be0ba70 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -22,5 +22,12 @@ public function run(): void $gitlabPublic->settings->is_static = true; $gitlabPublic->settings->save(); } + + $railpackStatic = Application::where('uuid', 'railpack-static')->first(); + if ($railpackStatic) { + $railpackStatic->load(['settings']); + $railpackStatic->settings->is_static = true; + $railpackStatic->settings->save(); + } } } From 4afcbbb2096df7db98663b2aeb93f315b85431a1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:09:24 +0100 Subject: [PATCH 005/100] fix(deployment): properly escape shell arguments in railpack prepare command --- app/Jobs/ApplicationDeploymentJob.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 13511957f..f4ec4abda 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2454,13 +2454,13 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { - $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->application->install_command) { - $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; From 18508e91495e2f5662ecbf918d82e104aaee4845 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:13:26 +0200 Subject: [PATCH 006/100] fix(railpack): pass build and start commands via --env instead of dedicated flags Replace --build-cmd and --start-cmd with --env RAILPACK_BUILD_CMD and --env RAILPACK_START_CMD to align with how install_command is already passed, matching the expected railpack CLI interface. --- app/Jobs/ApplicationDeploymentJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 26cef35d7..e9b4667ab 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2498,10 +2498,10 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); } if ($this->application->start_command) { - $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); } if ($this->application->install_command) { $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); From 519a186e841d0d5c3f56c38af8aeb7cfc7d63cdd Mon Sep 17 00:00:00 2001 From: Tristan Rhodes Date: Thu, 9 Apr 2026 09:38:56 -0600 Subject: [PATCH 007/100] fix: normalize oauth emails before matching users --- app/Http/Controllers/OauthController.php | 9 ++- tests/Feature/OauthControllerTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/OauthControllerTest.php diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 3a3f18c9c..4038fe63e 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -19,7 +19,12 @@ public function callback(string $provider) { try { $oauthUser = get_socialite_provider($provider)->user(); - $user = User::whereEmail($oauthUser->email)->first(); + $email = trim((string) $oauthUser->email); + if ($email === '') { + abort(403, 'OAuth provider did not return an email address'); + } + $email = strtolower($email); + $user = User::whereEmail($email)->first(); if (! $user) { $settings = instanceSettings(); if (! $settings->is_registration_enabled) { @@ -28,7 +33,7 @@ public function callback(string $provider) $user = User::create([ 'name' => $oauthUser->name, - 'email' => $oauthUser->email, + 'email' => $email, ]); } Auth::login($user); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php new file mode 100644 index 000000000..af5fb0658 --- /dev/null +++ b/tests/Feature/OauthControllerTest.php @@ -0,0 +1,79 @@ + 0, + 'is_registration_enabled' => false, + ]); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + ]); +}); + +it('logs in an existing user when the oauth provider returns a mixed-case email', function () { + config()->set('app.maintenance.driver', 'file'); + + $user = User::factory()->create([ + 'email' => 'username@example.edu', + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => 'UserName@example.edu', + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->get(route('auth.callback', 'google')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); + expect(User::count())->toBe(1); +}); + +it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::firstOrCreate([ + 'id' => 0, + ], [ + 'is_registration_enabled' => false, + ])->update([ + 'is_registration_enabled' => true, + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => $providerEmail, + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', 'google')); + + $response->assertRedirect('/login'); + expect(User::count())->toBe(0); +})->with([ + 'null email' => [null], + 'blank email' => [' '], +]); From 0649a424b8468ff8be371643e7ed8d7a5ff539b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:48:17 +0200 Subject: [PATCH 008/100] fix(buildpack): revert default build pack to nixpacks and reorder selector Change default build_pack from railpack back to nixpacks in all new application flows (GithubPrivateRepository, GithubPrivateRepositoryDeployKey, PublicGitRepository) and reorder the build pack dropdown so Nixpacks appears before Railpack across all relevant views. Add feature tests covering the nixpacks default and selector ordering. --- .../Project/New/GithubPrivateRepository.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 2 +- .../Project/New/PublicGitRepository.php | 2 +- .../project/application/general.blade.php | 2 +- ...ub-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/public-git-repository.blade.php | 2 +- ...pplicationGeneralBuildpackSelectorTest.php | 68 +++++++++++++++++++ .../NewApplicationBuildpackDefaultsTest.php | 43 ++++++++++++ 9 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/ApplicationGeneralBuildpackSelectorTest.php create mode 100644 tests/Feature/NewApplicationBuildpackDefaultsTest.php diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index b9db7373f..be7daddd7 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -63,7 +63,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 1acf5bc18..e81139792 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -46,7 +46,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 8213f3cd0..fb24ba284 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -58,7 +58,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 87465c5d3..2aab1ab92 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,8 +31,8 @@
- + diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 3c3313643..ca3c977a7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,8 +51,8 @@ class="loading loading-xs dark:text-warning loading-spinner">
- + diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 31dbed038..acbff15a6 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -82,8 +82,8 @@ @endforeach - + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 03fc71a5d..1df5cf907 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,8 +41,8 @@ helper="You can select other branches after configuration is done." /> @endif - + diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php new file mode 100644 index 000000000..9b4c4c00d --- /dev/null +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -0,0 +1,68 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); +}); + +test('existing application buildpack selector lists nixpacks before railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSeeInOrder([ + '', + '', + ], false); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php new file mode 100644 index 000000000..49c1ee7b2 --- /dev/null +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -0,0 +1,43 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('new application buildpack defaults', function () { + test('github app repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('deploy key repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow defaults to nixpacks and lists railpack second', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow keeps railpack available after branch lookup', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('branchFound', true) + ->assertSeeInOrder(['Nixpacks', 'Railpack']); + }); +}); From d7e1b7ec375c8f4d0700e845161c5f24ac51b8be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:45:42 +0200 Subject: [PATCH 009/100] feat(railpack): add config merging, beta badge, and nodejs seeder example - Implement railpack.json + generated config deep merging logic in ApplicationDeploymentJob with JSON validation and assoc array checks - Label Railpack as "Beta" in all build pack selectors and show a visible beta badge when railpack is selected in new-app forms - Add railpack-nodejs Fastify example to ApplicationSeeder - Add ApplicationSeederTest and ApplicationDeploymentRailpackConfigTest covering config merge behavior and seeder correctness --- app/Jobs/ApplicationDeploymentJob.php | 169 ++++++++++++++- database/seeders/ApplicationSeeder.php | 16 ++ .../project/application/general.blade.php | 10 +- ...ub-private-repository-deploy-key.blade.php | 10 +- .../new/github-private-repository.blade.php | 10 +- .../new/public-git-repository.blade.php | 10 +- ...pplicationGeneralBuildpackSelectorTest.php | 20 +- tests/Feature/ApplicationSeederTest.php | 51 +++++ .../NewApplicationBuildpackDefaultsTest.php | 10 +- ...pplicationDeploymentRailpackConfigTest.php | 195 ++++++++++++++++++ 10 files changed, 482 insertions(+), 19 deletions(-) create mode 100644 tests/Feature/ApplicationSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackConfigTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e9b4667ab..d5c6b1c80 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -33,6 +33,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use JsonException; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; + private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json'; + + private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + public $tries = 1; public $timeout = 3600; @@ -2487,28 +2492,170 @@ private function generate_railpack_env_variables(): void $this->env_railpack_args = $this->env_railpack_args->implode(' '); } - private function build_railpack_image(): void + private function decode_railpack_config(string $config, string $source): array { - $this->generate_railpack_env_variables(); + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception); + } - // Step 1: Generate build plan with railpack prepare + if (! is_array($decoded)) { + throw new DeploymentException("Invalid {$source}: expected a JSON object."); + } + + return $decoded; + } + + private function is_assoc_array(array $value): bool + { + if ($value === []) { + return false; + } + + return array_keys($value) !== range(0, count($value) - 1); + } + + private function merge_railpack_config(array $base, array $overrides): array + { + foreach ($overrides as $key => $value) { + if ( + array_key_exists($key, $base) + && is_array($base[$key]) + && is_array($value) + && $this->is_assoc_array($base[$key]) + && $this->is_assoc_array($value) + ) { + $base[$key] = $this->merge_railpack_config($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + private function railpack_config_overrides(): array + { + return []; + } + + private function railpack_prepare_environment_variables(): Collection + { + $variables = collect([]); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + if ($this->application->build_command) { + $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); + } + + if ($this->application->start_command) { + $variables->put('RAILPACK_START_CMD', $this->application->start_command); + } + + return $variables; + } + + private function generated_railpack_config_relative_path(): string + { + return self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generated_railpack_config_absolute_path(): string + { + return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generate_railpack_config_file(): ?string + { + $repositoryConfig = []; + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"), + 'hidden' => true, + 'save' => 'railpack_config_exists', + ]); + + if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH), + 'hidden' => true, + 'save' => 'railpack_repository_config', + ]); + + $repositoryConfig = $this->decode_railpack_config( + $this->saved_outputs->get('railpack_repository_config', ''), + 'repository railpack.json' + ); + } + + $overrides = $this->railpack_config_overrides(); + if ($repositoryConfig === [] && $overrides === []) { + return null; + } + + $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides); + if (! array_key_exists('$schema', $mergedConfig)) { + $mergedConfig['$schema'] = 'https://schema.railpack.com'; + } + + try { + $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception); + } + + $configPath = $this->generated_railpack_config_absolute_path(); + $encodedConfig = base64_encode($encodedConfig); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"), + 'hidden' => true, + ] + ); + + return $this->generated_railpack_config_relative_path(); + } + + private function railpack_prepare_command(?string $configFilePath = null): string + { $prepare_command = 'railpack prepare'; + $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() + ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) + ->implode(' '); + + if ($prepareEnvironmentVariables !== '') { + $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + } if ($this->env_railpack_args) { $prepare_command .= " {$this->env_railpack_args}"; } - if ($this->application->build_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); - } - if ($this->application->start_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); - } - if ($this->application->install_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + + if ($configFilePath) { + $prepare_command .= ' --config-file '.escapeShellValue($configFilePath); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + return $prepare_command; + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + $railpackConfigPath = $this->generate_railpack_config_file(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = $this->railpack_prepare_command($railpackConfigPath); + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index edb28c377..212bcce79 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -47,6 +47,22 @@ public function run(): void 'source_id' => 1, 'source_type' => GithubApp::class, ]); + Application::create([ + 'uuid' => 'railpack-nodejs', + 'name' => 'Railpack NodeJS Fastify Example', + 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/nodejs', + 'build_pack' => 'railpack', + 'ports_exposes' => '3000', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 2aab1ab92..382f05278 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -32,7 +32,7 @@ - + @@ -236,11 +236,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
-
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration - automatically. + @if ($buildPack === 'nixpacks') +
+ + Nixpacks + will detect the required configuration automatically. Framework Specific Docs
+ @endif @endif @endif diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index ca3c977a7..249ded1f7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -52,7 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"> - + @@ -61,6 +61,14 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -93,6 +93,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -52,6 +52,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
Nixpacks', - '', + '', ], false); }); + +test('existing application shows railpack beta badge in build helper copy', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('Railpack') + ->assertSee('Beta'); +}); diff --git a/tests/Feature/ApplicationSeederTest.php b/tests/Feature/ApplicationSeederTest.php new file mode 100644 index 000000000..ac39ea4a7 --- /dev/null +++ b/tests/Feature/ApplicationSeederTest.php @@ -0,0 +1,51 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ApplicationSeeder::class, + ]); + + $nixpacksExample = Application::where('uuid', 'nodejs')->first(); + $railpackExample = Application::where('uuid', 'railpack-nodejs')->first(); + + expect($nixpacksExample) + ->not->toBeNull() + ->and($nixpacksExample->name)->toBe('NodeJS Fastify Example') + ->and($nixpacksExample->build_pack)->toBe('nixpacks') + ->and($nixpacksExample->base_directory)->toBe('/nodejs') + ->and($nixpacksExample->ports_exposes)->toBe('3000'); + + expect($railpackExample) + ->not->toBeNull() + ->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example') + ->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io') + ->and($railpackExample->repository_project_id)->toBe(603035348) + ->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples') + ->and($railpackExample->git_branch)->toBe('v4.x') + ->and($railpackExample->base_directory)->toBe('/nodejs') + ->and($railpackExample->build_pack)->toBe('railpack') + ->and($railpackExample->ports_exposes)->toBe('3000') + ->and($railpackExample->environment_id)->toBe(1) + ->and($railpackExample->destination_id)->toBe(0) + ->and($railpackExample->source_id)->toBe(1); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php index 49c1ee7b2..f1bcdcb65 100644 --- a/tests/Feature/NewApplicationBuildpackDefaultsTest.php +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -38,6 +38,14 @@ test('public repository flow keeps railpack available after branch lookup', function () { Livewire::test(PublicGitRepository::class, ['type' => 'public']) ->set('branchFound', true) - ->assertSeeInOrder(['Nixpacks', 'Railpack']); + ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']) + ->assertSee('Beta'); + }); + + test('deploy key repository flow shows railpack beta label in build pack selector', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->set('current_step', 'repository') + ->assertSee('Railpack (Beta)') + ->assertSee('Beta'); }); }); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php new file mode 100644 index 000000000..5cd238b99 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -0,0 +1,195 @@ +recordedCommands[] = $commands; + } +} + +function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array +{ + $job = new TestableRailpackDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $application = new Application($applicationAttributes); + + foreach ([ + 'application' => $application, + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'saved_outputs' => new Collection($savedOutputs), + 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + ] as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($job, $arguments); +} + +it('deep merges repository railpack config with coolify overrides', function () { + $repositoryConfigJson = json_encode([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ], JSON_THROW_ON_ERROR); + + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build:prod', + 'start_command' => 'node server.js', + ], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => $repositoryConfigJson, + ], + ); + + $repositoryConfig = invokeRailpackMethod( + $job, + $reflection, + 'decode_railpack_config', + [$repositoryConfigJson, 'repository railpack.json'], + ); + $overrides = [ + 'deploy' => [ + 'variables' => [ + 'APP_ENV' => 'production', + ], + ], + 'packages' => [ + 'python' => '3.13', + ], + ]; + $generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]); + + expect($generatedConfig)->toMatchArray([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + 'python' => '3.13', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + 'APP_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ]); +}); + +it('writes a generated railpack config file when repository config exists', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => json_encode([ + '$schema' => 'https://schema.railpack.com', + 'steps' => [ + 'build' => [ + 'commands' => ['npm run build'], + ], + ], + ], JSON_THROW_ON_ERROR), + ], + ); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBe('.coolify/railpack.generated.json'); + expect($job->recordedCommands)->toHaveCount(3); +}); + +it('does not generate a railpack config file for command overrides alone', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBeNull(); + expect($job->recordedCommands)->toHaveCount(1); +}); + +it('fails fast when repository railpack config is invalid json', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => '{"steps":{"build":', + ], + ); + + expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file')) + ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); +}); + +it('builds railpack prepare command using process env vars for command overrides', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ], + ); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_prepare_command', + ['.coolify/railpack.generated.json'], + ); + + expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); + expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); + expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); + expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); + expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); +}); From 3c51b1aaccee3fdd906c33f9f5527f3fded49e6b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:01:52 +0200 Subject: [PATCH 010/100] fix(railpack): pass command overrides through supported prepare/build args Use Railpack's install env handling and dedicated CLI flags for build/start overrides, and forward install commands into docker build secrets so image builds stay aligned with prepare-time configuration. Update the railpack config test to cover the new command format. --- app/Jobs/ApplicationDeploymentJob.php | 47 +++++++++---------- ...pplicationDeploymentRailpackConfigTest.php | 14 +++--- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d5c6b1c80..40f917601 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2540,25 +2540,6 @@ private function railpack_config_overrides(): array return []; } - private function railpack_prepare_environment_variables(): Collection - { - $variables = collect([]); - - if ($this->application->install_command) { - $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); - } - - if ($this->application->build_command) { - $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); - } - - if ($this->application->start_command) { - $variables->put('RAILPACK_START_CMD', $this->application->start_command); - } - - return $variables; - } - private function generated_railpack_config_relative_path(): string { return self::RAILPACK_GENERATED_CONFIG_PATH; @@ -2627,12 +2608,17 @@ private function generate_railpack_config_file(): ?string private function railpack_prepare_command(?string $configFilePath = null): string { $prepare_command = 'railpack prepare'; - $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() - ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) - ->implode(' '); - if ($prepareEnvironmentVariables !== '') { - $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + if ($this->application->install_command) { + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + } + + if ($this->application->build_command) { + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + } + + if ($this->application->start_command) { + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->env_railpack_args) { @@ -2681,11 +2667,22 @@ private function build_railpack_image(): void $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; } + $installCommandEnv = ''; + $installCommandSecret = ''; + if ($this->application->install_command) { + $installCommandEnv = 'env RAILPACK_INSTALL_CMD='.escapeShellValue($this->application->install_command).' '; + $installCommandSecret = ' --secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'; + $cache_args .= ' --build-arg secrets-hash='.$this->generate_secrets_hash(collect([ + 'RAILPACK_INSTALL_CMD' => $this->application->install_command, + ])); + } + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && docker buildx build --builder coolify-railpack' + ." && {$installCommandEnv}docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" ." {$cache_args}" + ."{$installCommandSecret}" .' -f /artifacts/railpack-plan.json' .' --progress plain' .' --load' diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 5cd238b99..63ad618ae 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -167,7 +167,7 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); }); -it('builds railpack prepare command using process env vars for command overrides', function () { +it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () { [$job, $reflection] = makeRailpackDeploymentJob( [ 'install_command' => 'npm ci', @@ -183,13 +183,15 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ['.coolify/railpack.generated.json'], ); - expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); - expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); - expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); - expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('railpack prepare'); + expect($command)->toContain('--env '.escapeshellarg('RAILPACK_INSTALL_CMD=npm ci')); + expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build')); + expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js')); expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); - expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); + expect($command)->not->toContain('RAILPACK_BUILD_CMD='); + expect($command)->not->toContain('RAILPACK_START_CMD='); }); From f1f53a31ab94227bcac767ec0dac8f9cffca76d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:31:57 +0000 Subject: [PATCH 011/100] build(deps): bump follow-redirects in /docker/coolify-realtime Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docker/coolify-realtime/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 174077562..eae81be6a 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -165,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", From 60d8aba323bf3332f2e5772d321106e2e5fd617c Mon Sep 17 00:00:00 2001 From: Hendrik Kleinwaechter Date: Wed, 22 Apr 2026 21:18:18 +0200 Subject: [PATCH 012/100] feat: configurable stop grace period for applications Adds stop_grace_period to application settings (seconds, 1-3600, default 30). Used in place of the hardcoded docker stop -t 30 in the four places that stop application containers: rolling update shutdown, manual stop, stop on another server, and preview deployment stop. Non-positive values fall back to the default via ($val > 0) ? $val : default, with tests covering 0 and -10 so the cast does not blow up if a bad value ever lands in the db. Picks up Jack Coy's work from #7125 which went dormant. His commits are squashed here with credit below. Co-authored-by: Jack Coy --- app/Actions/Application/StopApplication.php | 5 +- .../Application/StopApplicationOneServer.php | 6 ++- app/Jobs/ApplicationDeploymentJob.php | 13 ++++-- app/Livewire/Project/Application/Advanced.php | 35 ++++++++++++++ app/Livewire/Project/Application/Previews.php | 5 +- app/Models/ApplicationSetting.php | 1 + bootstrap/helpers/constants.php | 1 + ...p_grace_period_to_application_settings.php | 31 +++++++++++++ .../project/application/advanced.blade.php | 16 ++++++- .../Unit/ApplicationSettingStaticCastTest.php | 46 +++++++++++++++++++ 10 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index e86e30f04..2badcbb67 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -36,10 +36,13 @@ public function handle(Application $application, bool $previewDeployments = fals : getCurrentApplicationContainerStatus($server, $application->id, 0); $containersToStop = $containers->pluck('Names')->toArray(); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index bf9fdee72..cb51bc865 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -20,13 +20,17 @@ public function handle(Application $application, Server $server) } try { $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $timeout = ($application->settings->stop_grace_period > 0) + ? $application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + if ($containers->count() > 0) { foreach ($containers as $container) { $containerName = data_get($container, 'Names'); if ($containerName) { instant_remote_process( [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], $server diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..229f46cd8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3310,14 +3310,21 @@ private function build_image() private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { - $timeout = isDev() ? 1 : 30; + if (isDev()) { + $timeout = 1; + } else { + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; + } + if ($skipRemove) { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true] + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..862181ecf 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -61,6 +61,9 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $gpuOptions = null; + #[Validate(['string', 'nullable'])] + public ?string $stopGracePeriod = null; + #[Validate(['boolean'])] public bool $isBuildServerEnabled = false; @@ -145,6 +148,10 @@ public function syncData(bool $toModel = false) $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } + + // Load stop_grace_period separately since it has its own save handler + // Convert null to empty string to prevent dirty detection issues + $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } private function resetDefaultLabels() @@ -252,6 +259,34 @@ public function saveCustomName() } } + public function saveStopGracePeriod() + { + try { + $this->authorize('update', $this->application); + + // Convert empty string to null, otherwise cast to integer + $value = ($this->stopGracePeriod === '' || $this->stopGracePeriod === null) + ? null + : (int) $this->stopGracePeriod; + + // Validate the integer value + if ($value !== null && ($value < 1 || $value > 3600)) { + $this->dispatch('error', 'Stop grace period must be between 1 and 3600 seconds.'); + + return; + } + + // Save to model + $this->application->settings->stop_grace_period = $value; + $this->application->settings->save(); + + // User feedback + $this->dispatch('success', 'Stop grace period updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index c887e9b83..9dd494f5c 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -338,10 +338,13 @@ public function addDockerImagePreview() private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); + $timeout = ($this->application->settings->stop_grace_period > 0) + ? $this->application->settings->stop_grace_period + : DEFAULT_STOP_GRACE_PERIOD_SECONDS; foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 731a9b5da..c365bd187 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -26,6 +26,7 @@ class ApplicationSetting extends Model 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', + 'stop_grace_period' => 'integer', ]; protected $fillable = [ diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bae2573de..ee6a3bc03 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -16,6 +16,7 @@ '@yearly' => '0 0 1 1 *', ]; const RESTART_MODE = 'unless-stopped'; +const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30; const DATABASE_DOCKER_IMAGES = [ 'bitnami/mariadb', diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php new file mode 100644 index 000000000..cc702ce5c --- /dev/null +++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php @@ -0,0 +1,31 @@ +integer('stop_grace_period') + ->nullable() + ->after('use_build_secrets') + ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('stop_grace_period'); + }); + } +}; diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index a9f8c7233..362539a3c 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -86,7 +86,21 @@ helper="Readonly labels are disabled. You need to set the labels in the labels section." disabled instantSave id="isStripprefixEnabled" label="Strip Prefixes" canGate="update" :canResource="$application" /> @endif - +

Operations

+
+ + Save +

Logs

diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php index 35ab7faaf..d06ee920d 100644 --- a/tests/Unit/ApplicationSettingStaticCastTest.php +++ b/tests/Unit/ApplicationSettingStaticCastTest.php @@ -103,3 +103,49 @@ ->and($casts[$field])->toBe('boolean'); } }); + +it('casts stop_grace_period to integer', function () { + $setting = new ApplicationSetting; + $casts = $setting->getCasts(); + + expect($casts)->toHaveKey('stop_grace_period') + ->and($casts['stop_grace_period'])->toBe('integer'); +}); + +it('handles null stop_grace_period for default behavior', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = null; + + expect($setting->stop_grace_period)->toBeNull(); +}); + +it('casts stop_grace_period from string to integer', function () { + $setting = new ApplicationSetting; + $setting->stop_grace_period = '60'; + + expect($setting->stop_grace_period)->toBe(60) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period zero to integer (documents fallback trigger)', function () { + // Value of 0 is not a valid grace period — consumers guard with + // `($value > 0) ? $value : DEFAULT_STOP_GRACE_PERIOD_SECONDS`, so + // the cast itself must still round-trip cleanly without throwing. + $setting = new ApplicationSetting; + $setting->stop_grace_period = 0; + + expect($setting->stop_grace_period)->toBe(0) + ->and($setting->stop_grace_period)->toBeInt(); +}); + +it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () { + // Negative values should never be persisted (UI validates `min=1`), + // but if one slips through (direct DB write, older data), the cast + // must not throw and consumers will treat it as the fallback via the + // `> 0` guard. + $setting = new ApplicationSetting; + $setting->stop_grace_period = -10; + + expect($setting->stop_grace_period)->toBe(-10) + ->and($setting->stop_grace_period)->toBeInt(); +}); From 593006be88cefdf34bd3319f98e8ae541630e69f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:27:26 +0530 Subject: [PATCH 013/100] fix(validation): allow decimals for database backups max storage --- app/Http/Controllers/Api/DatabasesController.php | 16 ++++++++-------- .../project/database/backup-edit.blade.php | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index c05af152f..a6056b9f3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -639,10 +639,10 @@ public function update_by_uuid(Request $request) 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -703,10 +703,10 @@ public function create_backup(Request $request) 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); @@ -878,10 +878,10 @@ public function create_backup(Request $request) 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -933,10 +933,10 @@ public function update_backup(Request $request) 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d5c25916a..898283afd 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -106,7 +106,7 @@ min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
@@ -122,7 +122,7 @@ min="0" helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
From 36baf70637d864eb5680e4bb94b6fe10fce4b06d Mon Sep 17 00:00:00 2001 From: nehemiyawicks Date: Sun, 26 Apr 2026 19:30:05 +0530 Subject: [PATCH 014/100] fix: use --network host for Dockerfile buildpack builds Dockerfile buildpack was passing --network {custom_network_name} to docker build, but BuildKit only supports host, none, and default. Every other buildpack already uses --network host with --add-host flags. Aligned the Dockerfile path to match. Fixes #9804 --- app/Jobs/ApplicationDeploymentJob.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..84bb4a09d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3075,29 +3075,28 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - $safeNetwork = escapeshellarg($this->destination->network); if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); From e658d2f9a303aed81ba0a0b684e4910b66eba7d9 Mon Sep 17 00:00:00 2001 From: Romain ROCHAS Date: Mon, 27 Apr 2026 16:24:03 +0700 Subject: [PATCH 015/100] fix(magic env) HEX secrets creating double the length of their name --- bootstrap/helpers/shared.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f0f2cd73..d55a00a4c 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1463,13 +1463,13 @@ function generateEnvValue(string $command, Service|Application|null $service = n $generatedValue = base64_encode(Str::random(32)); break; case 'HEX_32': - $generatedValue = bin2hex(Str::random(32)); + $generatedValue = bin2hex(Str::random(16)); break; case 'HEX_64': - $generatedValue = bin2hex(Str::random(64)); + $generatedValue = bin2hex(Str::random(32)); break; case 'HEX_128': - $generatedValue = bin2hex(Str::random(128)); + $generatedValue = bin2hex(Str::random(64)); break; case 'USER': $generatedValue = Str::random(16); From 9a58e0fea25879d14d1b7f1afb7835dd7a15269f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:33:08 +0200 Subject: [PATCH 016/100] fix(logs): disable auto-scroll on user scroll-up, re-enable on scroll-to-bottom Add wheel, touch, and keyboard event handlers to log containers in deployment and get-logs views. Auto-follow disables when user scrolls up; re-enables when user scrolls back to bottom (within 10px threshold). --- .../application/deployment/show.blade.php | 51 +++++++++++- .../project/shared/get-logs.blade.php | 46 ++++++++-- templates/service-templates-latest.json | 83 ++++++++++++++----- templates/service-templates.json | 83 ++++++++++++++----- 4 files changed, 208 insertions(+), 55 deletions(-) diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index d6294f3c8..8618872f5 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,6 +9,9 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollDebounce: null, + isScrolling: false, + lastTouchY: 0, showTimestamps: true, searchQuery: '', matchCount: 0, @@ -19,9 +22,54 @@ scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { + this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, + disableFollow() { + if (!this.alwaysScroll) return; + this.alwaysScroll = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }, + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) { + this.disableFollow(); + } + }, + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, + handleScroll(event) { + if (this.isScrolling) return; + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); + } + }, 150); + }, scheduleScroll() { if (!this.alwaysScroll) return; this.rafId = requestAnimationFrame(() => { @@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { @@ -57,17 +89,14 @@ } }, handleScroll(event) { - if (!this.alwaysScroll || this.isScrolling) return; + if (this.isScrolling) return; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { const el = event.target; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 100) { - this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); } }, 150); }, @@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
@if ($outputs) diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index fdc99ae78..eb667fcb8 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC43JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FMQ09NXzMwMDAKICAgICAgLSBORVhUX1BVQkxJQ19MSUNFTlNFX0NPTlNFTlQ9YWdyZWUKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ05FWFRfUFVCTElDX1dFQkFQUF9VUkw9JHtTRVJWSUNFX1VSTF9DQUxDT019JwogICAgICAtICdORVhUX1BVQkxJQ19BUElfVjJfVVJMPSR7U0VSVklDRV9VUkxfQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0NBTENPTX0vYXBpL2F1dGgnCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTVNFQ1JFVH0nCiAgICAgIC0gJ0NBTEVORFNPX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfQ0FMQ09NS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPXBvc3RncmVzcWwKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke0RBVEFCQVNFX0hPU1Q6LXBvc3RncmVzcWx9LyR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSAnREFUQUJBU0VfRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gQ0FMQ09NX1RFTEVNRVRSWV9ESVNBQkxFRD0xCiAgICAgIC0gJ0VNQUlMX0ZST009JHtFTUFJTF9GUk9NfScKICAgICAgLSAnRU1BSUxfRlJPTV9OQU1FPSR7RU1BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX0hPU1Q9JHtFTUFJTF9TRVJWRVJfSE9TVH0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QT1JUPSR7RU1BSUxfU0VSVkVSX1BPUlR9JwogICAgICAtICdFTUFJTF9TRVJWRVJfVVNFUj0ke0VNQUlMX1NFUlZFUl9VU0VSfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1BBU1NXT1JEPSR7RU1BSUxfU0VSVkVSX1BBU1NXT1JEfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQVBQX05BTUU9IkNhbC5jb20iJwogICAgICAtICdBTExPV0VEX0hPU1ROQU1FUz1bIiR7U0VSVklDRV9VUkxfQ0FMQ09NfSJdJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3Jlc3FsCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICB2b2x1bWVzOgogICAgICAtICdjYWxjb20tcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FQXzMwMDAKICAgICAgLSBBRE1JTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfQURNSU4KICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYnVuCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcpLnRoZW4ociA9PiB7IGlmICghci5vaykgcHJvY2Vzcy5leGl0KDEpIH0pLmNhdGNoKCgpID0+IHByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgICBkZXBlbmRzX29uOgogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICd2YWxrZXktZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tc2F2ZSA2MCAxIC0tbG9nbGV2ZWwgd2FybmluZyAtLW1heG1lbW9yeS1wb2xpY3kgbm9ldmljdGlvbicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB2YWxrZXktY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9VUkxfSklUU0kKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gRU5BQkxFX0xFVFNFTkNSWVBUPTAKICAgICAgLSBFTkFCTEVfSFRUUF9SRURJUkVDVD0wCiAgICAgIC0gRElTQUJMRV9IVFRQUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ1hNUFBfQk9TSF9VUkxfQkFTRT1odHRwOi8vcHJvc29keTo1MjgwJwogICAgICAtIEpWQl9CUkVXRVJZX01VQz1qdmJicmV3ZXJ5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgICAgLSBqaWNvZm8KICAgICAgLSBqdmIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLXdlYjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6CiAgICAgICAgYWxpYXNlczoKICAgICAgICAgIC0gbWVldC5qaXRzaQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgcHJvc29keToKICAgIGltYWdlOiAnaml0c2kvcHJvc29keTpzdGFibGUtMTA4ODgnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQVVUSF9UWVBFPWludGVybmFsCiAgICAgIC0gRU5BQkxFX0FVVEg9MAogICAgICAtIEVOQUJMRV9HVUVTVFM9MQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfR1VFU1RfRE9NQUlOPWd1ZXN0Lm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtICdKSUNPRk9fQ09NUE9ORU5UX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSklDT0ZPX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pWQl9BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KVkJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWp2YjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQpuZXR3b3JrczoKICBtZWV0LmppdHNpOiBudWxsCg==", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNi4yLjQuMjMnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMzAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogIEJVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCngtYXBwLWVudjoKICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVApzZXJ2aWNlczoKICBwcm94eToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICAtICdTSVRFX0FERFJFU1M9OjgwJwogICAgICAtICdGSUxFX1NJWkVfTElNSVQ9JHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICAtICdCVUNLRVRfTkFNRT0ke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICB2b2x1bWVzOgogICAgICAtICdwcm94eV9jb25maWc6L2NvbmZpZycKICAgICAgLSAncHJveHlfZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWZyb250ZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovL2Bob3N0bmFtZWA6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHNwYWNlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtc3BhY2U6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd29ya2VyCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFkbWluOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1saXZlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYXBpOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtYXBpLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2FwaTovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYmVhdC13b3JrZXI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtaWdyYXRvcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIHJlc3RhcnQ6ICdubycKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LW1pZ3JhdG9yLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX21pZ3JhdG9yOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD1odHRwczovLyR7U0VSVklDRV9VUkxfUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUFdEPSR7U01UUF9QV0R9JwogICAgICAtICdTTVRQX1RMU19FTkFCTEVEPSR7U01UUF9UTFNfRU5BQkxFRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1JBTExMWX0nCiAgICAgIC0gJ0FMTE9XRURfRU1BSUxTPSR7QUxMT1dFRF9FTUFJTFN9JwogICAgICAtICdTVVBQT1JUX0VNQUlMPSR7U1VQUE9SVF9FTUFJTDotc3VwcG9ydEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", diff --git a/templates/service-templates.json b/templates/service-templates.json index 45e2185ed..cc909dc68 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNycKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBTENPTV8zMDAwCiAgICAgIC0gTkVYVF9QVUJMSUNfTElDRU5TRV9DT05TRU5UPWFncmVlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19XRUJBUFBfVVJMPSR7U0VSVklDRV9GUUROX0NBTENPTX0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQSV9WMl9VUkw9JHtTRVJWSUNFX0ZRRE5fQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9DQUxDT019L2FwaS9hdXRoJwogICAgICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9DQUxDT01TRUNSRVR9JwogICAgICAtICdDQUxFTkRTT19FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTUtFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gJ0RBVEFCQVNFX0RJUkVDVF9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7REFUQUJBU0VfSE9TVDotcG9zdGdyZXNxbH0vJHtQT1NUR1JFU19EQjotY2FsZW5kc299JwogICAgICAtIENBTENPTV9URUxFTUVUUllfRElTQUJMRUQ9MQogICAgICAtICdFTUFJTF9GUk9NPSR7RU1BSUxfRlJPTX0nCiAgICAgIC0gJ0VNQUlMX0ZST01fTkFNRT0ke0VNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9IT1NUPSR7RU1BSUxfU0VSVkVSX0hPU1R9JwogICAgICAtICdFTUFJTF9TRVJWRVJfUE9SVD0ke0VNQUlMX1NFUlZFUl9QT1JUfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1VTRVI9JHtFTUFJTF9TRVJWRVJfVVNFUn0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QQVNTV09SRD0ke0VNQUlMX1NFUlZFUl9QQVNTV09SRH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQUF9OQU1FPSJDYWwuY29tIicKICAgICAgLSAnQUxMT1dFRF9IT1NUTkFNRVM9WyIke1NFUlZJQ0VfRlFETl9DQUxDT019Il0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzcWwKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NhbGNvbS1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBUF8zMDAwCiAgICAgIC0gQURNSU5fS0VZPSRTRVJWSUNFX1BBU1NXT1JEX0FETUlOCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJ1bgogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vbG9jYWxob3N0OjMwMDAnKS50aGVuKHIgPT4geyBpZiAoIXIub2spIHByb2Nlc3MuZXhpdCgxKSB9KS5jYXRjaCgoKSA9PiBwcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogICAgZGVwZW5kc19vbjoKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjktYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXNhdmUgNjAgMSAtLWxvZ2xldmVsIHdhcm5pbmcgLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gdmFsa2V5LWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIEVOQUJMRV9BVVRIPTAKICAgICAgLSBFTkFCTEVfR1VFU1RTPTEKICAgICAgLSBFTkFCTEVfTEVUU0VOQ1JZUFQ9MAogICAgICAtIEVOQUJMRV9IVFRQX1JFRElSRUNUPTAKICAgICAgLSBESVNBQkxFX0hUVFBTPTEKICAgICAgLSBYTVBQX0RPTUFJTj1tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9BVVRIX0RPTUFJTj1hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0dVRVNUX0RPTUFJTj1ndWVzdC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9NVUNfRE9NQUlOPWNvbmZlcmVuY2UubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSAnWE1QUF9CT1NIX1VSTF9CQVNFPWh0dHA6Ly9wcm9zb2R5OjUyODAnCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSAnSklDT0ZPX0NPTVBPTkVOVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pJQ09GT19BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKVkJfQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSlZCfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcHJvc29keQogICAgICAtIGppY29mbwogICAgICAtIGp2YgogICAgdm9sdW1lczoKICAgICAgLSAnaml0c2ktd2ViOi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSBtZWV0LmppdHNpCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBwcm9zb2R5OgogICAgaW1hZ2U6ICdqaXRzaS9wcm9zb2R5OnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9GUUROX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwcm9zb2R5CiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1qdmI6L2NvbmZpZycKICAgIG5ldHdvcmtzOgogICAgICBtZWV0LmppdHNpOiBudWxsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hYm91dC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKbmV0d29ya3M6CiAgbWVldC5qaXRzaTogbnVsbAo=", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAzMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDNzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjYuMi40LjIzJwogICAgdXNlcjogJzEwMToxMDEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3Q6ODEyMy9waW5nIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICBTSVRFX0FERFJFU1M6ICcke1NJVEVfQUREUkVTUzotOjgwfScKeC1tcS1lbnY6CiAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCngtbGl2ZS1lbnY6CiAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAp4LWFwcC1lbnY6CiAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgLSAnU0lURV9BRERSRVNTPTo4MCcKICAgICAgLSAnRklMRV9TSVpFX0xJTUlUPSR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgLSAnQlVDS0VUX05BTUU9JHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgdm9sdW1lczoKICAgICAgLSAncHJveHlfY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ3Byb3h5X2RhdGE6L2RhdGEnCiAgICBkZXBlbmRzX29uOgogICAgICAtIHdlYgogICAgICAtIGFwaQogICAgICAtIHNwYWNlCiAgICAgIC0gYWRtaW4KICAgICAgLSBsaXZlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICB3ZWI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXNwYWNlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBhZG1pbjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWFkbWluOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBsaXZlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWFwaS5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19hcGk6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICByZXN0YXJ0OiAnbm8nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1taWdyYXRvci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19taWdyYXRvcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9SQUxMTFl9JwogICAgICAtICdBTExPV0VEX0VNQUlMUz0ke0FMTE9XRURfRU1BSUxTfScKICAgICAgLSAnU1VQUE9SVF9FTUFJTD0ke1NVUFBPUlRfRU1BSUw6LXN1cHBvcnRAZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICAgIC0gJ1NNVFBfVExTX0VOQUJMRUQ9JHtTTVRQX1RMU19FTkFCTEVEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BXRD0ke1NNVFBfUFdEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", From 9408620d5f47836241a9b10516296c5311786832 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:35:32 +0200 Subject: [PATCH 017/100] fix(terminal): add WS heartbeat and fix proxy idle disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxies (Cloudflare, nginx) drop idle WebSocket connections before the application notices, leaving clients typing into dead sockets. - Add server-side ping/pong heartbeat (30s) in terminal-server.js; terminate unresponsive clients instead of letting connections go stale - Move client keepAlive interval start to the connect event so it restarts correctly after reconnects - Remove hidden-tab keepalive short-circuit — server pings now own liveness; suppressing client pings while hidden masked proxy drops - Fix clearAllTimers to use clearTimeout for one-shot timers - On visibility resume, probe with a 5s timeout instead of the default 35s so half-open sockets are detected quickly - Bump coolify-realtime to 1.0.14 across all compose files --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 22 +++++++++++ other/nightly/docker-compose.prod.yml | 2 +- other/nightly/docker-compose.windows.yml | 2 +- resources/js/terminal.js | 38 +++++++++++++------ .../Feature/RealtimeTerminalPackagingTest.php | 25 ++++++++++++ 8 files changed, 79 insertions(+), 16 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7504b6ba8..f2f6946fb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ 'coolify' => [ 'version' => '4.0.0', 'helper_version' => '1.0.13', - 'realtime_version' => '1.0.13', + 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 3ae77857f..470f4af1e 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -105,7 +105,12 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const HEARTBEAT_INTERVAL_MS = 30000; + wss.on('connection', async (ws, req) => { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); @@ -167,6 +172,23 @@ wss.on('connection', async (ws, req) => { }); }); +const heartbeat = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + logTerminal('warn', 'Terminating WS due to missed protocol pong.'); + return ws.terminate(); + } + ws.isAlive = false; + try { + ws.ping(); + } catch (_) { + // ignore — close handler will follow + } + }); +}, HEARTBEAT_INTERVAL_MS); + +wss.on('close', () => clearInterval(heartbeat)); + const messageHandlers = { message: (session, data) => session.ptyProcess.write(data), resize: (session, { cols, rows }) => { diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/resources/js/terminal.js b/resources/js/terminal.js index aa5f37353..923d09d86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -75,8 +75,6 @@ export function initializeTerminalComponent() { focusWhenReady(); }); - this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); - this.$watch('terminalActive', (active) => { if (!active && this.keepAliveInterval) { clearInterval(this.keepAliveInterval); @@ -150,8 +148,11 @@ export function initializeTerminalComponent() { }, clearAllTimers() { - [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] - .forEach(timer => timer && clearInterval(timer)); + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] + .forEach(timer => timer && clearTimeout(timer)); this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; @@ -282,6 +283,13 @@ export function initializeTerminalComponent() { this.pendingCommand = null; } + // (Re)start application-level keepalive on every successful connect. + // Server-side WebSocket protocol pings are the primary heartbeat; this + // adds a JSON-level ping in case the server-side is older or restarting. + if (!this.keepAliveInterval) { + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -494,11 +502,6 @@ export function initializeTerminalComponent() { }, keepAlive() { - // Skip keepalive when document is hidden to prevent unnecessary disconnects - if (!this.isDocumentVisible) { - return; - } - if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -524,10 +527,23 @@ export function initializeTerminalComponent() { logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { - // Send immediate ping to verify connection is still alive + // Connection may be half-open after Cloudflare/proxy idle drop while hidden. + // Probe with a short timeout (5s) instead of the default 35s — force a + // reconnect quickly if no pong arrives so the user is not stuck typing + // into a dead socket. this.heartbeatMissed = 0; this.sendMessage({ ping: true }); - this.resetPingTimeout(); + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + } + this.pingTimeoutId = setTimeout(() => { + logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.'); + try { + this.socket.close(4000, 'Visibility-resume timeout'); + } catch (_) { + // ignore — close handler will run on its own + } + }, 5000); } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { // Was connected before but now disconnected - attempt reconnection this.reconnectAttempts = 0; diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index e8fa5ff76..8f4da3a62 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -32,3 +32,28 @@ ->toContain('if (!terminalDebugEnabled) {') ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); }); + +it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('ws.isAlive = true;') + ->toContain("ws.on('pong'") + ->toContain('ws.ping();') + ->toContain('ws.terminate();') + ->toContain('HEARTBEAT_INTERVAL_MS'); +}); + +it('removes the keepalive short-circuit that fired when the tab was hidden', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects'); +}); + +it('uses a fast probe timeout when the tab regains visibility', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("'Visibility-resume timeout'"); +}); From cabcd8f699dc3ac400edeb63f3a9ac0c0c2260c6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:26:31 +0200 Subject: [PATCH 018/100] fix(terminal): add idle timeout, reconnect replay, and scrollback preservation - Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS) - Buffer client messages during async auth/IP fetch to prevent race-condition message loss on fast reconnects - Replay last sent command after transient reconnect so PTY respawns without user interaction - Preserve scrollback on disconnect/reconnect; write visible timestamp markers instead of wiping term state - Handle idle-timeout sentinel on client with user-facing error message --- docker/coolify-realtime/terminal-server.js | 81 +++++++++++++++---- resources/js/terminal.js | 49 +++++++++-- .../Feature/RealtimeTerminalPackagingTest.php | 47 +++++++++++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 470f4af1e..11695173b 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -106,13 +106,24 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); const userId = generateUserId(); - const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; + ws.userId = userId; + const userSession = { + ws, + userId, + ptyProcess: null, + isActive: false, + authorizedIPs: [], + lastActivityAt: Date.now(), + authReady: false, + pendingMessages: [], + }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, @@ -122,6 +133,26 @@ wss.on('connection', async (ws, req) => { hasLaravelSession: Boolean(laravelSession), }; + // Register socket handlers up front so messages sent immediately by the client + // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch + // below is still pending. + ws.on('message', (message) => { + if (userSession.authReady) { + handleMessage(userSession, message); + } else { + userSession.pendingMessages.push(message); + } + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); + // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); @@ -153,23 +184,17 @@ wss.on('connection', async (ws, req) => { } userSessions.set(userId, userSession); + userSession.authReady = true; logTerminal('log', 'Terminal websocket connection established.', { ...connectionContext, authorizedHostCount: userSession.authorizedIPs.length, + bufferedMessages: userSession.pendingMessages.length, }); - ws.on('message', (message) => { - handleMessage(userSession, message); - }); - ws.on('error', (err) => handleError(err, userId)); - ws.on('close', (code, reason) => { - logTerminal('log', 'Terminal websocket connection closed.', { - userId, - code, - reason: reason?.toString(), - }); - handleClose(userId); - }); + // Drain any messages that arrived while we were waiting on the IP auth call. + while (userSession.pendingMessages.length > 0) { + handleMessage(userSession, userSession.pendingMessages.shift()); + } }); const heartbeat = setInterval(() => { @@ -184,14 +209,41 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } + + const session = ws.userId ? userSessions.get(ws.userId) : null; + if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { + const idleMs = Date.now() - session.lastActivityAt; + logTerminal('warn', 'Closing terminal session due to idle timeout.', { + userId: ws.userId, + idleMs, + idleTimeoutMs: IDLE_TIMEOUT_MS, + }); + try { + ws.send('idle-timeout'); + } catch (_) { + // ignore — close still attempted below + } + killPtyProcess(ws.userId); + setTimeout(() => { + try { + ws.close(1000, 'Idle timeout'); + } catch (_) { + // ignore — already closed + } + }, 100); + } }); }, HEARTBEAT_INTERVAL_MS); wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { - message: (session, data) => session.ptyProcess.write(data), + message: (session, data) => { + session.lastActivityAt = Date.now(); + session.ptyProcess.write(data); + }, resize: (session, { cols, rows }) => { + session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 923d09d86..fe13c1b21 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -42,6 +42,10 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, + // Last successfully sent SSH command — replayed after a transient reconnect + // so the PTY respawns automatically. Cleared on intentional terminations + // (pty-exited, idle-timeout, unprocessable). + lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -162,9 +166,17 @@ export function initializeTerminalComponent() { resetTerminal() { if (this.term) { - this.$wire.dispatch('error', 'Terminal websocket connection lost.'); - this.term.reset(); - this.term.clear(); + this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...'); + // Preserve scrollback so the user keeps the context of their previous + // session. Print a visible marker so they know where the disconnect + // happened. Old PTY shell state cannot be restored — this is purely + // a visual carry-over. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`); + } catch (_) { + // ignore — terminal not ready to receive writes + } this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; @@ -277,10 +289,15 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready + // Flush any buffered command from before WebSocket was ready, otherwise + // replay the last command so a transient reconnect respawns the PTY + // automatically without requiring the user to click Connect again. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + } else if (this.lastSentCommand) { + logTerminal('log', '[Terminal] Replaying last command after reconnect.'); + this.sendMessage(this.lastSentCommand); } // (Re)start application-level keepalive on every successful connect. @@ -362,6 +379,9 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + if (message && message.command) { + this.lastSentCommand = message; + } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -395,7 +415,15 @@ export function initializeTerminalComponent() { this.term.open(document.getElementById('terminal')); this.term._initialized = true; } else { - this.term.reset(); + // Already initialized — this is a reconnect or a follow-up command. + // Preserve scrollback so the user keeps context. Write a visible + // separator so the new shell prompt is easy to spot. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`); + } catch (_) { + // ignore — fall through; xterm will render the new prompt anyway + } } this.terminalActive = true; this.term.focus(); @@ -423,6 +451,7 @@ export function initializeTerminalComponent() { } else if (event.data === 'unprocessable') { if (this.term) this.term.reset(); this.terminalActive = false; + this.lastSentCommand = null; this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed @@ -431,9 +460,19 @@ export function initializeTerminalComponent() { this.terminalActive = false; this.term.reset(); this.commandBuffer = ''; + this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if (event.data === 'idle-timeout') { + this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); + this.terminalActive = false; + if (this.term) { + this.term.reset(); + } + this.commandBuffer = ''; + this.lastSentCommand = null; + this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 8f4da3a62..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -57,3 +57,50 @@ expect($terminalClient) ->toContain("'Visibility-resume timeout'"); }); + +it('closes idle terminal sessions after 30 minutes on the server', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->toContain('lastActivityAt') + ->toContain("ws.send('idle-timeout');") + ->toContain("ws.close(1000, 'Idle timeout');"); +}); + +it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'idle-timeout'") + ->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('replays the last command on reconnect so the PTY respawns automatically', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('lastSentCommand') + ->toContain('Replaying last command after reconnect.') + ->toContain('this.lastSentCommand = null;'); +}); + +it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('authReady: false') + ->toContain('pendingMessages: []') + ->toContain('userSession.pendingMessages.push(message)') + ->toContain('userSession.authReady = true'); +}); + +it('preserves terminal scrollback across transient reconnects', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('── Connection lost at') + ->toContain('── Reconnected at') + // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. + ->not->toContain("this.term.reset();\n this.term.clear();"); +}); From 1368026f20d88045601f6d1ad03b53e9aff7d2b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:29:32 +0200 Subject: [PATCH 019/100] fix(terminal): remove verbose websocket message logging --- docker/coolify-realtime/terminal-server.js | 6 ------ resources/js/terminal.js | 2 -- 2 files changed, 8 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 11695173b..f5760279f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -271,12 +271,6 @@ function handleMessage(userSession, message) { return; } - logTerminal('log', 'Received websocket message.', { - userId: userSession.userId, - keys: Object.keys(parsed), - isActive: userSession.isActive, - }); - Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index fe13c1b21..7a7fc8536 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -396,8 +396,6 @@ export function initializeTerminalComponent() { }, handleSocketMessage(event) { - logTerminal('log', '[Terminal] Received WebSocket message:', event.data); - // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; From b3339d1034079a75e5a05c48c840c1f308fc89fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:37:31 +0200 Subject: [PATCH 020/100] feat(railpack): add buildpack control var filtering and dev seeder Extract NIXPACKS_/RAILPACK_ prefix filtering into a reusable `scopeWithoutBuildpackControlVariables` query scope on EnvironmentVariable. Apply scope consistently to runtime vars, runtime preview vars, and buildtime var generation in ApplicationDeploymentJob. Refactor `generate_railpack_env_variables` to return a Collection. Add `RAILPACK_FRONTEND_IMAGE` constant and bake it into the coolify-helper Dockerfile as a build arg. Add DevelopmentRailpackExamplesSeeder (dev/local env only) for seeding example Railpack apps, wired into DatabaseSeeder. Add tests: - ApplicationDeploymentControlVarFilteringTest: verifies control vars are excluded from runtime and buildtime envs - DevelopmentRailpackExamplesSeederTest: verifies seeder behavior - ApplicationDeploymentRailpackEnvParityTest: parity checks for env handling across build/runtime paths --- app/Jobs/ApplicationDeploymentJob.php | 175 ++++--- app/Models/Application.php | 6 +- app/Models/EnvironmentVariable.php | 31 +- database/seeders/DatabaseSeeder.php | 6 + .../DevelopmentRailpackExamplesSeeder.php | 441 ++++++++++++++++++ docker/coolify-helper/Dockerfile | 2 + openapi.json | 12 +- openapi.yaml | 12 +- ...ationDeploymentControlVarFilteringTest.php | 287 ++++++++++++ .../DevelopmentRailpackExamplesSeederTest.php | 121 +++++ ...pplicationDeploymentRailpackConfigTest.php | 37 +- ...icationDeploymentRailpackEnvParityTest.php | 124 +++++ 12 files changed, 1177 insertions(+), 77 deletions(-) create mode 100644 database/seeders/DevelopmentRailpackExamplesSeeder.php create mode 100644 tests/Feature/ApplicationDeploymentControlVarFilteringTest.php create mode 100644 tests/Feature/DevelopmentRailpackExamplesSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e12c8eabf..fbf981483 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -53,6 +53,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const RAILPACK_FRONTEND_IMAGE_ENV = '${RAILPACK_FRONTEND_IMAGE}'; + public $tries = 1; public $timeout = 3600; @@ -1259,11 +1261,11 @@ private function generate_runtime_environment_variables() $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { - $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key'); } else { - $sorted_environment_variables = $this->application->environment_variables->sortBy('id'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { @@ -1634,6 +1636,7 @@ private function generate_buildtime_environment_variables() // 4. Add user-defined build-time variables LAST (highest priority - can override everything) if ($this->pull_request_id === 0) { $sorted_environment_variables = $this->application->environment_variables() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -1686,6 +1689,7 @@ private function generate_buildtime_environment_variables() } } else { $sorted_environment_variables = $this->application->environment_variables_preview() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -2468,28 +2472,114 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } - private function generate_railpack_env_variables(): void + private function generate_railpack_env_variables(): Collection + { + $variables = $this->railpack_build_variables(); + + $this->env_railpack_args = $variables + ->map(function ($value, $key) { + return '--env '.escapeShellValue("{$key}={$value}"); + }) + ->implode(' '); + + return $variables; + } + + private function railpack_environment_variables_collection(): Collection { - $this->env_railpack_args = collect([]); if ($this->pull_request_id === 0) { - foreach ($this->application->railpack_environment_variables as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); - } - } - } else { - foreach ($this->application->railpack_environment_variables_preview as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); - } - } + return $this->application->railpack_environment_variables; } - // Note: COOLIFY_* vars are NOT passed to railpack prepare because railpack treats - // all --env vars as secrets that must be provided during docker buildx build. - // COOLIFY_* vars are informational and available at runtime via .env file. + return $this->application->railpack_environment_variables_preview; + } - $this->env_railpack_args = $this->env_railpack_args->implode(' '); + private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string + { + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); + if (is_null($resolvedValue) || $resolvedValue === '') { + return null; + } + + if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { + return trim($resolvedValue, "'"); + } + + return $resolvedValue; + } + + private function railpack_build_variables(): Collection + { + $variables = $this->railpack_environment_variables_collection() + ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { + $value = $this->normalize_resolved_build_variable_value($environmentVariable); + if (is_null($value) || $value === '') { + return []; + } + + return [$environmentVariable->key => $value]; + }); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + return $variables; + } + + private function railpack_build_environment_prefix(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return 'env '.$variables + ->map(function ($value, $key) { + return "{$key}=".escapeShellValue($value); + }) + ->implode(' ').' '; + } + + private function railpack_build_secret_flags(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return ' '.$variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + } + + private function railpack_build_command(string $imageName, Collection $variables): string + { + $cacheArgs = ''; + if ($this->force_rebuild) { + $cacheArgs = '--no-cache'; + } else { + $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'"; + } + + if ($variables->isNotEmpty()) { + $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables); + } + + $environmentPrefix = $this->railpack_build_environment_prefix($variables); + $secretFlags = $this->railpack_build_secret_flags($variables); + + return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + ." && {$environmentPrefix}docker buildx build --builder coolify-railpack" + ." {$this->addHosts} --network host" + .' --build-arg BUILDKIT_SYNTAX="'.self::RAILPACK_FRONTEND_IMAGE_ENV.'"' + ." {$cacheArgs}" + ."{$secretFlags}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$imageName}" + ." {$this->workdir}"; } private function decode_railpack_config(string $config, string $source): array @@ -2609,10 +2699,6 @@ private function railpack_prepare_command(?string $configFilePath = null): strin { $prepare_command = 'railpack prepare'; - if ($this->application->install_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); - } - if ($this->application->build_command) { $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } @@ -2636,7 +2722,7 @@ private function railpack_prepare_command(?string $configFilePath = null): strin private function build_railpack_image(): void { - $this->generate_railpack_env_variables(); + $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); // Step 1: Generate build plan with railpack prepare @@ -2660,34 +2746,7 @@ private function build_railpack_image(): void $this->pull_latest_image($this->application->static_image); } - $cache_args = ''; - if ($this->force_rebuild) { - $cache_args = '--no-cache'; - } else { - $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; - } - - $installCommandEnv = ''; - $installCommandSecret = ''; - if ($this->application->install_command) { - $installCommandEnv = 'env RAILPACK_INSTALL_CMD='.escapeShellValue($this->application->install_command).' '; - $installCommandSecret = ' --secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'; - $cache_args .= ' --build-arg secrets-hash='.$this->generate_secrets_hash(collect([ - 'RAILPACK_INSTALL_CMD' => $this->application->install_command, - ])); - } - - $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - ." && {$installCommandEnv}docker buildx build --builder coolify-railpack" - ." {$this->addHosts} --network host" - ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" - ." {$cache_args}" - ."{$installCommandSecret}" - .' -f /artifacts/railpack-plan.json' - .' --progress plain' - .' --load' - ." -t {$image_name}" - ." {$this->workdir}"; + $build_command = $this->railpack_build_command($image_name, $railpackVariables); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2859,7 +2918,7 @@ private function generate_env_variables() // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -2871,7 +2930,7 @@ private function generate_env_variables() } } else { $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -3951,7 +4010,7 @@ private function add_build_env_variables_to_dockerfile() if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { @@ -3973,7 +4032,7 @@ private function add_build_env_variables_to_dockerfile() } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 2201b1d16..2a364e13f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -960,8 +960,7 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%') - ->where('key', 'not like', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables() @@ -996,8 +995,7 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%') - ->where('key', 'not like', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables_preview() diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 83212267c..25e2dcfb3 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -32,6 +33,8 @@ )] class EnvironmentVariable extends BaseModel { + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -78,7 +81,7 @@ class EnvironmentVariable extends BaseModel protected static function booted() { - static::created(function (EnvironmentVariable $environment_variable) { + static::created(function (ModelsEnvironmentVariable $environment_variable) { if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) { $found = ModelsEnvironmentVariable::where('key', $environment_variable->key) ->where('resourceable_type', Application::class) @@ -109,7 +112,7 @@ protected static function booted() ]); }); - static::saving(function (EnvironmentVariable $environmentVariable) { + static::saving(function (ModelsEnvironmentVariable $environmentVariable) { $environmentVariable->updateIsShared(); }); } @@ -119,6 +122,30 @@ public function service() return $this->belongsTo(Service::class); } + public function scopeWithoutBuildpackControlVariables(Builder $query): Builder + { + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + $query->where('key', 'not like', "{$prefix}%"); + } + + return $query; + } + + public static function isBuildpackControlKey(?string $key): bool + { + if (blank($key)) { + return false; + } + + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + if (str($key)->startsWith($prefix)) { + return true; + } + } + + return false; + } + protected function value(): Attribute { return Attribute::make( diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 57ccab4ae..4f5c4431a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,5 +31,11 @@ public function run(): void CaSslCertSeeder::class, PersonalAccessTokenSeeder::class, ]); + + if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + $this->call([ + DevelopmentRailpackExamplesSeeder::class, + ]); + } } } diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php new file mode 100644 index 000000000..4629f29ca --- /dev/null +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -0,0 +1,441 @@ +isDevelopmentEnvironment()) { + $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.'); + + return; + } + + $this->ensureDevelopmentPrerequisitesExist(); + $destination = StandaloneDocker::query()->find(0); + + if (! $destination) { + throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); + } + + $environment = $this->prepareEnvironment(); + + foreach (self::examples() as $example) { + $this->upsertApplication($environment, $destination, $example); + } + } + + /** + * @return array> + */ + public static function examples(): array + { + return [ + [ + 'uuid' => 'railpack-simple-webserver', + 'name' => 'Railpack Simple Webserver Example', + 'base_directory' => '/node/simple-webserver', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-expressjs', + 'name' => 'Railpack Express.js Example', + 'base_directory' => '/node/expressjs', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-fastify', + 'name' => 'Railpack Fastify Example', + 'base_directory' => '/node/fastify', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nestjs', + 'name' => 'Railpack NestJS Example', + 'base_directory' => '/node/nestjs', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start:prod', + ], + [ + 'uuid' => 'railpack-adonisjs', + 'name' => 'Railpack AdonisJS Example', + 'base_directory' => '/node/adonisjs', + 'ports_exposes' => '3333', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-hono', + 'name' => 'Railpack Hono Example', + 'base_directory' => '/node/hono', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-koa', + 'name' => 'Railpack Koa Example', + 'base_directory' => '/node/koa', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nextjs-ssr', + 'name' => 'Railpack Next.js SSR Example', + 'base_directory' => '/node/nextjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nuxtjs-ssr', + 'name' => 'Railpack NuxtJS SSR Example', + 'base_directory' => '/node/nuxtjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000', + ], + [ + 'uuid' => 'railpack-astro-ssr', + 'name' => 'Railpack Astro SSR Example', + 'base_directory' => '/node/astro/ssr', + 'ports_exposes' => '4321', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-sveltekit-ssr', + 'name' => 'Railpack SvelteKit SSR Example', + 'base_directory' => '/node/sveltekit/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-tanstack-start-ssr', + 'name' => 'Railpack TanStack Start SSR Example', + 'base_directory' => '/node/tanstack-start/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-angular-ssr', + 'name' => 'Railpack Angular SSR Example', + 'base_directory' => '/node/angular/ssr', + 'ports_exposes' => '4000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-vue-ssr', + 'name' => 'Railpack Vue SSR Example', + 'base_directory' => '/node/vue/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-qwik-ssr', + 'name' => 'Railpack Qwik SSR Example', + 'base_directory' => '/node/qwik/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run serve', + ], + [ + 'uuid' => 'railpack-react-static', + 'name' => 'Railpack React Static Example', + 'base_directory' => '/node/react', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vite-static', + 'name' => 'Railpack Vite Static Example', + 'base_directory' => '/node/vite', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-eleventy-static', + 'name' => 'Railpack Eleventy Static Example', + 'base_directory' => '/node/eleventy', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/_site', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-gatsby-static', + 'name' => 'Railpack Gatsby Static Example', + 'base_directory' => '/node/gatsby', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/public', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-nextjs-static', + 'name' => 'Railpack Next.js Static Example', + 'base_directory' => '/node/nextjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/out', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-nuxtjs-static', + 'name' => 'Railpack NuxtJS Static Example', + 'base_directory' => '/node/nuxtjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-astro-static', + 'name' => 'Railpack Astro Static Example', + 'base_directory' => '/node/astro/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-sveltekit-static', + 'name' => 'Railpack SvelteKit Static Example', + 'base_directory' => '/node/sveltekit/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/build', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-tanstack-start-static', + 'name' => 'Railpack TanStack Start Static Example', + 'base_directory' => '/node/tanstack-start/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-angular-static', + 'name' => 'Railpack Angular Static Example', + 'base_directory' => '/node/angular/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist/static/browser', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vue-static', + 'name' => 'Railpack Vue Static Example', + 'base_directory' => '/node/vue/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-qwik-static', + 'name' => 'Railpack Qwik Static Example', + 'base_directory' => '/node/qwik/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + ]; + } + + private function ensureDevelopmentPrerequisitesExist(): void + { + Team::query()->firstOrCreate( + ['id' => 0], + [ + 'name' => 'Root Team', + 'description' => 'The root team', + 'personal_team' => true, + ], + ); + + PrivateKey::query()->firstOrCreate( + ['id' => 1], + [ + 'uuid' => 'ssh', + 'team_id' => 0, + 'name' => 'Testing Host Key', + 'description' => 'This is a test docker container', + 'private_key' => <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY, + ], + ); + + Server::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ], + ); + + StandaloneDocker::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'docker', + 'name' => 'Standalone Docker 1', + 'network' => 'coolify', + 'server_id' => 0, + ], + ); + + $this->ensurePublicGithubSourceExists(); + } + + private function ensurePublicGithubSourceExists(): void + { + GithubApp::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'github-public', + 'name' => 'Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'team_id' => 0, + ], + ); + } + + private function isDevelopmentEnvironment(): bool + { + return in_array(config('app.env'), ['local', 'development', 'dev'], true); + } + + private function prepareEnvironment(): Environment + { + $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); + $project->fill([ + 'name' => 'Railpack Examples', + 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.', + 'team_id' => 0, + ]); + $project->save(); + + $environment = $project->environments()->first(); + + if (! $environment) { + $environment = $project->environments()->create([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } else { + $environment->update([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } + + return $environment; + } + + /** + * @param array $example + */ + private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void + { + $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); + $application->fill([ + 'name' => $example['name'], + 'description' => $example['name'], + 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", + 'repository_project_id' => self::REPOSITORY_PROJECT_ID, + 'git_repository' => self::GIT_REPOSITORY, + 'git_branch' => self::GIT_BRANCH, + 'build_pack' => 'railpack', + 'ports_exposes' => $example['ports_exposes'], + 'base_directory' => $example['base_directory'], + 'publish_directory' => $example['publish_directory'] ?? null, + 'static_image' => 'nginx:alpine', + 'install_command' => $example['install_command'] ?? null, + 'build_command' => $example['build_command'] ?? null, + 'start_command' => $example['start_command'] ?? null, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + $application->save(); + + if ($application->trashed()) { + $application->restore(); + } + + $application->settings()->updateOrCreate( + ['application_id' => $application->id], + [ + 'is_static' => $example['is_static'] ?? false, + 'is_spa' => $example['is_spa'] ?? false, + ], + ); + } +} diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1b95efe6c..35798000b 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -34,6 +34,8 @@ ARG MISE_VERSION USER root WORKDIR /artifacts +ENV RAILPACK_VERSION=${RAILPACK_VERSION} +ENV RAILPACK_FRONTEND_IMAGE=ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION} RUN apk upgrade --no-cache && \ apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins diff --git a/openapi.json b/openapi.json index d8557e607..453289970 100644 --- a/openapi.json +++ b/openapi.json @@ -4386,8 +4386,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4398,8 +4398,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4956,7 +4956,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4968,7 +4968,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index df2515b06..a3844bb18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php new file mode 100644 index 000000000..0fa4af749 --- /dev/null +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -0,0 +1,287 @@ +recordedCommands[] = $commands; + + foreach ($commands as $command) { + $commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command; + + if (! is_string($commandString)) { + continue; + } + + if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) { + $this->writtenDockerfile = base64_decode($matches[1]) ?: null; + } + } + } +} + +function makeDeploymentControlVarFixture(array $applicationAttributes = []): array +{ + $team = Team::create([ + 'name' => 'Control Var Team', + 'description' => 'Team for deployment control var tests.', + 'personal_team' => false, + 'show_boarding' => false, + ]); + $project = Project::create([ + 'name' => 'Control Var Project', + 'team_id' => $team->id, + ]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ...$applicationAttributes, + ]); + + $application->settings()->update([ + 'inject_build_args_to_dockerfile' => true, + 'include_source_commit_in_build' => false, + 'is_env_sorting_enabled' => false, + ]); + + return [$application->fresh(), $server]; +} + +function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable +{ + return EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'is_multiline' => false, + 'is_literal' => false, + ...$attributes, + ]); +} + +function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array +{ + $job = new TestableControlVarFilteringDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class); + $queue->shouldReceive('addLogEntry')->andReturnNull(); + + $properties = [ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'build_pack' => $application->build_pack, + 'mainServer' => $server, + 'pull_request_id' => 0, + 'commit' => 'HEAD', + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'dockerfile_location' => '/Dockerfile', + 'container_name' => 'control-var-app', + 'coolify_variables' => null, + 'dockerSecretsSupported' => false, + ]; + + $mergedProperties = array_merge($properties, $overrides); + $mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []); + + if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) { + $mergedProperties['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $mergedProperties['pull_request_id'], + 'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'], + 'fqdn' => 'https://preview.example.com', + ]); + } + + foreach ($mergedProperties as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invoke($job); +} + +function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed +{ + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($job); +} + +it('filters buildpack control vars from generic build args', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + + invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); + + /** @var Collection $envArgs */ + $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); + + expect($envArgs->get('APP_ENV'))->toBe('production'); + expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse(); +}); + +it('filters buildpack control vars from preview build-time env files', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_preview' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 42, + ]); + + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from preview runtime env fallback', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_NAME', + 'value' => 'coolify', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + + $application->environment_variables_preview() + ->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION']) + ->delete(); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 99, + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from dockerfile arg injection', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'saved_outputs' => [ + 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", + ], + ]); + + invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); + + expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production'); + expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION='); + expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION='); +}); diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php new file mode 100644 index 000000000..2f224fda7 --- /dev/null +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -0,0 +1,121 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ]); +} + +it('can seed the railpack examples directly on a clean development database', function () { + config()->set('app.env', 'local'); + + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Team::query()->find(0))->not->toBeNull(); + expect(PrivateKey::query()->find(1))->not->toBeNull(); + expect(Server::query()->find(0))->not->toBeNull(); + expect(StandaloneDocker::query()->find(0))->not->toBeNull(); + expect(GithubApp::query()->find(0))->not->toBeNull(); + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue(); + expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); + +it('seeds the railpack examples in development mode', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project) + ->not->toBeNull() + ->and($project->name)->toBe('Railpack Examples') + ->and($project->environments)->toHaveCount(1) + ->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID); + + $applications = $project->applications()->with('settings')->orderBy('uuid')->get(); + + expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_branch === DevelopmentRailpackExamplesSeeder::GIT_BRANCH))->toBeTrue(); + + $nestjs = $applications->firstWhere('uuid', 'railpack-nestjs'); + $angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static'); + $eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static'); + + expect($nestjs) + ->not->toBeNull() + ->and($nestjs->base_directory)->toBe('/node/nestjs') + ->and($nestjs->ports_exposes)->toBe('3000') + ->and($nestjs->build_command)->toBe('npm run build') + ->and($nestjs->start_command)->toBe('npm run start:prod') + ->and($nestjs->settings->is_static)->toBeFalse(); + + expect($angularStatic) + ->not->toBeNull() + ->and($angularStatic->publish_directory)->toBe('/dist/static/browser') + ->and($angularStatic->ports_exposes)->toBe('80') + ->and($angularStatic->settings->is_static)->toBeTrue() + ->and($angularStatic->settings->is_spa)->toBeTrue(); + + expect($eleventyStatic) + ->not->toBeNull() + ->and($eleventyStatic->publish_directory)->toBe('/_site') + ->and($eleventyStatic->settings->is_static)->toBeTrue() + ->and($eleventyStatic->settings->is_spa)->toBeFalse(); +}); + +it('skips the railpack examples outside development mode', function () { + config()->set('app.env', 'testing'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse(); + expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse(); +}); + +it('is idempotent when run multiple times', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project)->not->toBeNull(); + expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 63ad618ae..e3516268c 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -30,6 +30,9 @@ function makeRailpackDeploymentJob(array $applicationAttributes = [], array $sav 'deployment_uuid' => 'deployment-uuid', 'saved_outputs' => new Collection($savedOutputs), 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + 'force_rebuild' => false, + 'addHosts' => '', + 'secrets_hash_key' => 'testing-app-key', ] as $property => $value) { $reflectionProperty = $reflection->getProperty($property); $reflectionProperty->setAccessible(true); @@ -175,6 +178,9 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ 'start_command' => 'node server.js', ], ); + $envRailpackArgsProperty = $reflection->getProperty('env_railpack_args'); + $envRailpackArgsProperty->setAccessible(true); + $envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'"); $command = invokeRailpackMethod( $job, @@ -184,8 +190,8 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ); expect($command)->toContain('railpack prepare'); - expect($command)->toContain('--env '.escapeshellarg('RAILPACK_INSTALL_CMD=npm ci')); expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'"); expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build')); expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js')); expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); @@ -195,3 +201,32 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->not->toContain('RAILPACK_BUILD_CMD='); expect($command)->not->toContain('RAILPACK_START_CMD='); }); + +it('builds railpack docker command with matching env and secret flags for all railpack variables', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_build_command', + [ + 'coollabsio/coolify:test', + collect([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + 'SECRET_JSON' => '{"token":"abc"}', + ]), + ], + ); + + expect($command)->toContain("env RAILPACK_NODE_VERSION='22'"); + expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'"); + expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'"); + expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'); + expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); + expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); + expect($command)->toContain(' --build-arg secrets-hash='); + expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="${RAILPACK_FRONTEND_IMAGE}"'); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php new file mode 100644 index 000000000..8ed7b2c41 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -0,0 +1,124 @@ +shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall'); + + $nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nodeVersion->forceFill([ + 'key' => 'RAILPACK_NODE_VERSION', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22'); + + $literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $literalValue->forceFill([ + 'key' => 'RAILPACK_CUSTOM_FLAG', + 'is_literal' => true, + 'is_multiline' => false, + ]); + $literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'"); + + $jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $jsonValue->forceFill([ + 'key' => 'RAILPACK_JSON', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}'); + + $nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nullValue->forceFill([ + 'key' => 'RAILPACK_NULL', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables') + ->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($variables->all())->toBe([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_CUSTOM_FLAG' => 'hello world', + 'RAILPACK_JSON' => '{"token":"abc"}', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + ]); + 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)->not->toContain('RAILPACK_NULL'); +}); + +it('uses preview railpack environment variables for preview deployments', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $previewValue->forceFill([ + 'key' => 'RAILPACK_PREVIEW_ONLY', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value'); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables_preview') + ->andReturn(collect([$previewValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 42); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + expect($variables->all())->toBe([ + 'RAILPACK_PREVIEW_ONLY' => 'preview-value', + ]); +}); From 81510e5f6bfae1a844a52e4e2ae78ffbbc4601e5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:30 +0200 Subject: [PATCH 021/100] Update versions.json --- openapi.json | 12 ++++++------ openapi.yaml | 12 ++++++------ versions.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/versions.json b/versions.json index 3307b7f2e..ef92cfe9e 100644 --- a/versions.json +++ b/versions.json @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" From a2096c6f680731247fd667550bde20df62a910b9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:37 +0200 Subject: [PATCH 022/100] feat(observability): add structured audit log channel for API and webhook events Introduce a dedicated `audit` log channel (daily rotation, configurable retention via LOG_AUDIT_DAYS) and a small `auditLog()` / `auditLogWebhookFailure()` helper used to record state-changing API operations and webhook events. Instrumented: - API mutation endpoints (create / update / delete / start / stop / restart) across applications, services, databases (incl. backups, env vars, storage), servers, projects + environments, scheduled tasks, private keys, GitHub apps, cloud provider tokens, Hetzner server provisioning, instance enable/disable. - Webhook signature verification outcomes for GitHub, GitLab, Bitbucket, Gitea and Stripe, plus the Sentinel push endpoint. - Authentication and authorization outcomes via the global exception handler and the `ApiAbility` middleware (unauthenticated, ability-denied, policy-denied). The helper is wrapped in try/catch so logging failures never affect the request path. Successful operations log at `info`; suspicious/denied requests log at `warning`. Operators wanting a failures-only feed can set `LOG_AUDIT_LEVEL=warning`. Includes a feature test suite covering the helper, the webhook providers and the new auth/authorization log paths. Co-Authored-By: Claude Opus 4.7 --- app/Exceptions/Handler.php | 22 +- .../Api/ApplicationsController.php | 170 +++++++ .../Api/CloudProviderTokensController.php | 30 +- .../Controllers/Api/DatabasesController.php | 197 ++++++++ app/Http/Controllers/Api/DeployController.php | 25 + app/Http/Controllers/Api/GithubController.php | 21 + .../Controllers/Api/HetznerController.php | 14 +- app/Http/Controllers/Api/OtherController.php | 8 + .../Controllers/Api/ProjectController.php | 37 ++ .../Api/ScheduledTasksController.php | 68 ++- .../Controllers/Api/SecurityController.php | 22 + .../Controllers/Api/ServersController.php | 37 +- .../Controllers/Api/ServicesController.php | 106 +++++ app/Http/Controllers/Webhook/Bitbucket.php | 27 ++ app/Http/Controllers/Webhook/Gitea.php | 21 + app/Http/Controllers/Webhook/Github.php | 39 ++ app/Http/Controllers/Webhook/Gitlab.php | 24 + app/Http/Controllers/Webhook/Stripe.php | 10 +- app/Http/Middleware/ApiAbility.php | 14 +- bootstrap/helpers/audit.php | 81 ++++ config/logging.php | 8 + openapi.json | 12 +- openapi.yaml | 12 +- routes/api.php | 30 ++ tests/Feature/Security/AuditLogTest.php | 445 ++++++++++++++++++ 25 files changed, 1438 insertions(+), 42 deletions(-) create mode 100644 bootstrap/helpers/audit.php create mode 100644 tests/Feature/Security/AuditLogTest.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 71de48bcd..58f21c793 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -4,8 +4,10 @@ use App\Models\InstanceSettings; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Psr\Log\LogLevel; use RuntimeException; use Sentry\Laravel\Integration; use Sentry\State\Scope; @@ -16,7 +18,7 @@ class Handler extends ExceptionHandler /** * A list of exception types with their corresponding custom log levels. * - * @var array, \Psr\Log\LogLevel::*> + * @var array, LogLevel::*> */ protected $levels = [ // @@ -25,7 +27,7 @@ class Handler extends ExceptionHandler /** * A list of the exception types that are not reported. * - * @var array> + * @var array> */ protected $dontReport = [ ProcessException::class, @@ -49,6 +51,13 @@ class Handler extends ExceptionHandler protected function unauthenticated($request, AuthenticationException $exception) { if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) { + if ($request->is('api/*')) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $exception->getMessage(), + 'guards' => $exception->guards(), + ], 'warning'); + } + return response()->json(['message' => $exception->getMessage()], 401); } @@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception) public function render($request, Throwable $e) { // Handle authorization exceptions for API routes - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { if ($request->is('api/*') || $request->expectsJson()) { + if ($request->is('api/*')) { + auditLog('api.auth.policy_denied', [ + 'reason' => $e->getMessage(), + 'route' => $request->route()?->getName() ?? $request->path(), + ], 'warning'); + } + // Get the custom message from the policy if available $message = $e->getMessage(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index eb2e7fc53..bb72ebabe 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type) } } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'application_uuid' => data_get($application, 'uuid'), + 'application_name' => data_get($application, 'name'), + 'application_type' => $type, + 'build_pack' => data_get($application, 'build_pack'), + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), @@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type) StartService::dispatch($service); } + auditLog('api.application.created', [ + 'team_id' => $teamId, + 'service_uuid' => data_get($service, 'uuid'), + 'service_name' => data_get($service, 'name'), + 'application_type' => $type, + 'instant_deploy' => (bool) ($instantDeploy ?? false), + ]); + return response()->json(serializeApiResponse([ 'uuid' => data_get($service, 'uuid'), 'domains' => data_get($service, 'domains'), @@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.application.deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + return response()->json([ 'message' => 'Application deletion request queued.', ]); @@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request) } $application->save(); + auditLog('api.application.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + if ($instantDeploy) { $deployment_uuid = new Cuid2; @@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } else { return response()->json([ @@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request) $returnedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.application.env_bulk_upserted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_count' => $returnedEnvs->count(), + ]); + return response()->json($returnedEnvs)->setStatusCode(201); } @@ -3446,6 +3534,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3471,6 +3567,14 @@ public function create_env(Request $request) 'resourceable_id' => $application->id, ]); + auditLog('api.application.env_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + ]); + return response()->json([ 'uuid' => $env->uuid, ])->setStatusCode(201); @@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request) 'message' => 'Environment variable not found.', ], 404); } + $envKey = $found_env->key; + $envUuid = $found_env->uuid; $found_env->forceDelete(); + auditLog('api.application.env_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json([ 'message' => 'Environment variable deleted.', ]); @@ -3675,6 +3788,15 @@ public function action_deploy(Request $request) ); } + auditLog('api.application.deployed', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'force_rebuild' => $force, + 'instant_deploy' => $instant_deploy, + ]); + return response()->json( [ 'message' => 'Deployment request queued.', @@ -3763,6 +3885,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopApplication::dispatch($application, false, $dockerCleanup); + auditLog('api.application.stopped', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Application stopping request queued.', @@ -3853,6 +3982,13 @@ public function action_restart(Request $request) ], 200); } + auditLog('api.application.restarted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + ]); + return response()->json( [ 'message' => 'Restart request queued.', @@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.application.storage_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.application.storage_created', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.application.storage_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } @@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons $preview->delete(); CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + auditLog('api.application.preview_deleted', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $pullRequestId, + ]); + return response()->json(['message' => 'Preview deletion request queued.']); } } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 5be82a31c..d652f2ba1 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -244,7 +245,7 @@ public function store(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -286,6 +287,13 @@ public function store(Request $request) 'name' => $body['name'], ]); + auditLog('api.cloud_token.created', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $cloudProviderToken->uuid, + 'cloud_token_name' => $cloudProviderToken->name, + 'provider' => $cloudProviderToken->provider, + ]); + return response()->json([ 'uuid' => $cloudProviderToken->uuid, ])->setStatusCode(201); @@ -355,7 +363,7 @@ public function update(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -389,6 +397,14 @@ public function update(Request $request) $token->update(array_intersect_key($body, array_flip($allowedFields))); + auditLog('api.cloud_token.updated', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))), + ]); + return response()->json([ 'uuid' => $token->uuid, ]); @@ -464,8 +480,18 @@ public function destroy(Request $request) return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $tokenProvider = $token->provider; $token->delete(); + auditLog('api.cloud_token.deleted', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $tokenUuid, + 'cloud_token_name' => $tokenName, + 'provider' => $tokenProvider, + ]); + return response()->json(['message' => 'Cloud provider token deleted.']); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a6056b9f3..dc9b6f5b5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -596,6 +596,14 @@ public function update_by_uuid(Request $request) StopDatabaseProxy::dispatch($database); } + auditLog('api.database.updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'message' => 'Database updated.', ]); @@ -826,6 +834,15 @@ public function create_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backupConfig->uuid, + 'frequency' => $backupConfig->frequency, + 'save_s3' => (bool) $backupConfig->save_s3, + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'uuid' => $backupConfig->uuid, 'message' => 'Backup configuration created successfully.', @@ -1045,6 +1062,14 @@ public function update_backup(Request $request) dispatch(new DatabaseBackupJob($backupConfig)); } + auditLog('api.database.backup_updated', [ + 'team_id' => $teamId, + 'backup_uuid' => $backupConfig->uuid, + 'database_id' => $backupConfig->database_id, + 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))), + 'backup_now' => (bool) $request->backup_now, + ]); + return response()->json([ 'message' => 'Database backup configuration updated', ]); @@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; @@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; @@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; @@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; @@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; @@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; @@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type) $payload['external_db_url'] = $database->external_db_url; } + auditLog('api.database.created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $type->value, + 'server_uuid' => $serverUuid, + 'is_public' => (bool) $database->is_public, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json(serializeApiResponse($payload))->setStatusCode(201); } @@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.database.deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json([ 'message' => 'Database deletion request queued.', ]); @@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request) $backup->delete(); DB::commit(); + auditLog('api.database.backup_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'delete_s3' => $deleteS3, + 'executions_deleted' => $executions->count(), + ]); + return response()->json([ 'message' => 'Backup configuration and all executions deleted.', ]); @@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request) $execution->delete(); + auditLog('api.database.backup_execution_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $request->scheduled_backup_uuid, + 'execution_uuid' => $request->execution_uuid, + 'delete_s3' => $deleteS3, + ]); + return response()->json([ 'message' => 'Backup execution deleted.', ]); @@ -2633,6 +2751,13 @@ public function action_deploy(Request $request) } StartDatabase::dispatch($database); + auditLog('api.database.started', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database starting request queued.', @@ -2724,6 +2849,14 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopDatabase::dispatch($database, $dockerCleanup); + auditLog('api.database.stopped', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Database stopping request queued.', @@ -2801,6 +2934,13 @@ public function action_restart(Request $request) RestartDatabase::dispatch($database); + auditLog('api.database.restarted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'database_type' => $database->type(), + ]); + return response()->json( [ 'message' => 'Database restarting request queued.', @@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.database.env_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveEnvData($env)); } + auditLog('api.database.env_bulk_upserted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -3266,6 +3419,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.database.env_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } @@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.database.env_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.database.storage_created', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.database.storage_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.database.storage_deleted', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 6ff06c10a..c93731d68 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -281,6 +281,14 @@ public function cancel_deployment(Request $request) } } + auditLog('api.deployment.cancelled', [ + 'team_id' => $teamId, + 'deployment_uuid' => $deployment->deployment_uuid, + 'application_id' => $application?->id, + 'application_uuid' => $application?->uuid, + 'server_id' => $deployment->server_id, + ]); + return response()->json([ 'message' => 'Deployment cancelled successfully.', 'deployment_uuid' => $deployment->deployment_uuid, @@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $message = $result['message']; } else { $message = "Application {$resource->name} deployment queued."; + auditLog('api.deployment.triggered', [ + 'resource_type' => 'application', + 'application_uuid' => $resource->uuid, + 'application_name' => $resource->name, + 'deployment_uuid' => $deployment_uuid?->toString(), + 'force_rebuild' => $force, + 'pull_request_id' => $pr, + ]); } break; case Service::class: @@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st } StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; + auditLog('api.service.deployed', [ + 'service_uuid' => $resource->uuid, + 'service_name' => $resource->name, + ]); break; default: // Database resource - check authorization @@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st $resource->save(); $message = "Database {$resource->name} started."; + auditLog('api.database.started', [ + 'database_uuid' => $resource->uuid, + 'database_name' => $resource->name, + 'database_type' => $resource->getMorphClass(), + ]); break; } diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 9a2cf2b9f..651969b97 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -271,6 +271,12 @@ public function create_github_app(Request $request) $githubApp = GithubApp::create($payload); + auditLog('api.github_app.created', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + ]); + return response()->json($githubApp, 201); } catch (\Throwable $e) { return handleError($e); @@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id) // Update the GitHub app $githubApp->update($payload); + auditLog('api.github_app.updated', [ + 'team_id' => $teamId, + 'github_app_uuid' => $githubApp->uuid, + 'github_app_name' => $githubApp->name, + 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])), + ]); + return response()->json([ 'message' => 'GitHub app updated successfully', 'data' => $githubApp, @@ -734,8 +747,16 @@ public function delete_github_app($github_app_id) ], 409); } + $deletedUuid = $githubApp->uuid; + $deletedName = $githubApp->name; $githubApp->delete(); + auditLog('api.github_app.deleted', [ + 'team_id' => $teamId, + 'github_app_uuid' => $deletedUuid, + 'github_app_name' => $deletedName, + ]); + return response()->json([ 'message' => 'GitHub app deleted successfully', ]); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 092c48594..2f35ba576 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Controller; @@ -12,6 +13,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -550,7 +552,7 @@ public function createServer(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -717,9 +719,17 @@ public function createServer(Request $request) // Validate server if requested if ($request->instant_validate) { - \App\Actions\Server\ValidateServer::dispatch($server); + ValidateServer::dispatch($server); } + auditLog('api.hetzner_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hetzner_server_id' => $hetznerServer['id'], + 'ip' => $ipAddress, + ]); + return response()->json([ 'uuid' => $server->uuid, 'hetzner_server_id' => $hetznerServer['id'], diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 49468b597..5ac274f93 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -85,11 +85,15 @@ public function enable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to enable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => true]); + auditLog('api.instance.enabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API enabled.'], 200); } @@ -137,11 +141,15 @@ public function disable_api(Request $request) return invalidTokenResponse(); } if ($teamId !== '0') { + auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning'); + return response()->json(['message' => 'You are not allowed to disable the API.'], 403); } $settings = instanceSettings(); $settings->update(['is_api_enabled' => false]); + auditLog('api.instance.disabled', ['team_id' => $teamId]); + return response()->json(['message' => 'API disabled.'], 200); } diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index ec2e300ff..0e5f6e93b 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -264,6 +264,12 @@ public function create_project(Request $request) 'team_id' => $teamId, ]); + auditLog('api.project.created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + ]); + return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -382,6 +388,13 @@ public function update_project(Request $request) $project->update($request->only($allowedFields)); + auditLog('api.project.updated', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -460,8 +473,16 @@ public function delete_project(Request $request) return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } + $projectUuid = $project->uuid; + $projectName = $project->name; $project->delete(); + auditLog('api.project.deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $projectUuid, + 'project_name' => $projectName, + ]); + return response()->json(['message' => 'Project deleted.']); } @@ -641,6 +662,13 @@ public function create_environment(Request $request) 'name' => $request->name, ]); + auditLog('api.project.environment_created', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'environment_name' => $environment->name, + ]); + return response()->json([ 'uuid' => $environment->uuid, ])->setStatusCode(201); @@ -723,8 +751,17 @@ public function delete_environment(Request $request) return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); } + $envUuid = $environment->uuid; + $envName = $environment->name; $environment->delete(); + auditLog('api.project.environment_deleted', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $envUuid, + 'environment_name' => $envName, + ]); + return response()->json(['message' => 'Environment deleted.']); } } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 6245dc2ec..d7b109918 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); } - private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + private function listTasks(Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json return response()->json($tasks); } - private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function createTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I $task->save(); + auditLog('api.scheduled_task.created', [ + 'team_id' => $teamId, + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json($this->removeSensitiveData($task), 201); } - private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function updateTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I $task->update($request->only($allowedFields)); + auditLog('api.scheduled_task.updated', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json($this->removeSensitiveData($task), 200); } - private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function deleteTask(Request $request, Application|Service $resource): JsonResponse { $this->authorize('update', $resource); - $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); - if (! $deleted) { + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { return response()->json(['message' => 'Scheduled task not found.'], 404); } + $taskUuid = $task->uuid; + $taskName = $task->name; + $task->delete(); + + auditLog('api.scheduled_task.deleted', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $taskUuid, + 'task_name' => $taskName, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + return response()->json(['message' => 'Scheduled task deleted.']); } - private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + private function getExecutions(Request $request, Application|Service $resource): JsonResponse { $this->authorize('view', $resource); @@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource): ), ] )] - public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina ), ] )] - public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il ), ] )] - public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_application_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht ), ] )] - public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H ), ] )] - public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi ), ] )] - public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + public function executions_by_service_uuid(Request $request): JsonResponse { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 2c62928c2..e59c40866 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -232,6 +232,13 @@ public function create_key(Request $request) 'private_key' => $request->private_key, ]); + auditLog('api.private_key.created', [ + 'team_id' => $teamId, + 'private_key_uuid' => $key->uuid, + 'private_key_name' => $key->name, + 'fingerprint' => $fingerPrint, + ]); + return response()->json(serializeApiResponse([ 'uuid' => $key->uuid, ]))->setStatusCode(201); @@ -333,6 +340,13 @@ public function update_key(Request $request) } $foundKey->update($request->only($allowedFields)); + auditLog('api.private_key.updated', [ + 'team_id' => $teamId, + 'private_key_uuid' => $foundKey->uuid, + 'private_key_name' => $foundKey->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, ]))->setStatusCode(201); @@ -415,8 +429,16 @@ public function delete_key(Request $request) ], 422); } + $keyUuid = $key->uuid; + $keyName = $key->name; $key->forceDelete(); + auditLog('api.private_key.deleted', [ + 'team_id' => $teamId, + 'private_key_uuid' => $keyUuid, + 'private_key_name' => $keyName, + ]); + return response()->json([ 'message' => 'Private Key deleted.', ]); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index c13c6665c..b33e85c78 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -13,6 +13,7 @@ use App\Models\Project; use App\Models\Server as ModelsServer; use App\Rules\ValidServerIp; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -477,7 +478,7 @@ public function create_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -564,6 +565,14 @@ public function create_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'ip' => $server->ip, + 'is_build_server' => (bool) $request->is_build_server, + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -647,7 +656,7 @@ public function update_server(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -718,6 +727,13 @@ public function update_server(Request $request) ValidateServer::dispatch($server); } + auditLog('api.server.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $server->uuid, ])->setStatusCode(201); @@ -807,6 +823,9 @@ public function delete_server(Request $request) } } + $deletedUuid = $server->uuid; + $deletedName = $server->name; + $deletedIp = $server->ip; $server->delete(); DeleteServer::dispatch( $server->id, @@ -816,6 +835,14 @@ public function delete_server(Request $request) $server->team_id ); + auditLog('api.server.deleted', [ + 'team_id' => $teamId, + 'server_uuid' => $deletedUuid, + 'server_name' => $deletedName, + 'ip' => $deletedIp, + 'force' => $force, + ]); + return response()->json(['message' => 'Server deleted.']); } @@ -881,6 +908,12 @@ public function validate_server(Request $request) } ValidateServer::dispatch($server); + auditLog('api.server.validated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + return response()->json(['message' => 'Validation started.'], 201); } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 20560635e..11a23d46c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -486,6 +486,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => $oneClickServiceName ?? null, + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -650,6 +658,14 @@ public function create_service(Request $request) StartService::dispatch($service); } + auditLog('api.service.created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'service_type' => 'docker_compose', + 'instant_deploy' => (bool) $instantDeploy, + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request) dockerCleanup: $request->boolean('docker_cleanup', true) ); + auditLog('api.service.deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json([ 'message' => 'Service deletion request queued.', ]); @@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request) StartService::dispatch($service); } + auditLog('api.service.updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + return response()->json([ 'uuid' => $service->uuid, 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), @@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request) } $env->save(); + auditLog('api.service.env_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request) $updatedEnvs->push($this->removeSensitiveData($env)); } + auditLog('api.service.env_bulk_upserted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_count' => $updatedEnvs->count(), + ]); + return response()->json($updatedEnvs)->setStatusCode(201); } @@ -1506,6 +1548,13 @@ public function create_env(Request $request) 'comment' => $request->comment ?? null, ]); + auditLog('api.service.env_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + ]); + return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } + $envKey = $env->key; + $envUuid = $env->uuid; $env->forceDelete(); + auditLog('api.service.env_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $envUuid, + 'env_key' => $envKey, + ]); + return response()->json(['message' => 'Environment variable deleted.']); } @@ -1668,6 +1726,12 @@ public function action_deploy(Request $request) } StartService::dispatch($service); + auditLog('api.service.deployed', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + ]); + return response()->json( [ 'message' => 'Service starting request queued.', @@ -1759,6 +1823,13 @@ public function action_stop(Request $request) $dockerCleanup = $request->boolean('docker_cleanup', true); StopService::dispatch($service, false, $dockerCleanup); + auditLog('api.service.stopped', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'docker_cleanup' => $dockerCleanup, + ]); + return response()->json( [ 'message' => 'Service stopping request queued.', @@ -1846,6 +1917,13 @@ public function action_restart(Request $request) $pullLatest = $request->boolean('latest'); RestartService::dispatch($service, $pullLatest); + auditLog('api.service.restarted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'service_name' => $service->name, + 'pull_latest' => $pullLatest, + ]); + return response()->json( [ 'message' => 'Service restarting request queued.', @@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse ]); } + auditLog('api.service.storage_created', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path, + ]); + return response()->json($storage, 201); } @@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse $storage->save(); + auditLog('api.service.storage_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storage->uuid ?? null, + 'storage_id' => $storage->id, + 'storage_type' => $request->type, + 'mount_path' => $storage->mount_path ?? null, + ]); + return response()->json($storage); } @@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse $storage->deleteStorageOnServer(); } + $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent'; + $storageMountPath = $storage->mount_path ?? null; $storage->delete(); + auditLog('api.service.storage_deleted', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'storage_uuid' => $storageUuid, + 'storage_type' => $storageType, + 'mount_path' => $storageMountPath, + ]); + return response()->json(['message' => 'Storage deleted.']); } } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ffa71b55a..eda2014a6 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -58,6 +58,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); if (empty($webhook_secret)) { + auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -70,6 +76,12 @@ public function manual(Request $request) $parts = explode('=', $x_bitbucket_token, 2); if (count($parts) !== 2 || $parts[0] !== 'sha256') { + auditLogWebhookFailure('bitbucket', 'malformed_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -81,6 +93,12 @@ public function manual(Request $request) $hash = $parts[1]; $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { + auditLogWebhookFailure('bitbucket', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_bitbucket_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -118,6 +136,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'bitbucket', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => $commit, + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 62adf5410..c6297905e 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -68,6 +68,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitea', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -78,6 +84,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('gitea', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitea_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -117,6 +129,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitea', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 4158016d0..7489b433f 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -82,6 +82,12 @@ public function manual(Request $request) foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); if (empty($webhook_secret)) { + auditLogWebhookFailure('github', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -92,6 +98,12 @@ public function manual(Request $request) } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'mode' => 'manual', + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -131,6 +143,15 @@ public function manual(Request $request) 'message' => $result['message'], ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -224,6 +245,13 @@ public function normal(Request $request) $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (config('app.env') !== 'local') { if (! hash_equals($x_hub_signature_256, $hmac)) { + auditLogWebhookFailure('github', 'invalid_signature', [ + 'mode' => 'app', + 'github_app_id' => $github_app->id, + 'github_app_name' => $github_app->name, + 'installation_target_id' => $x_github_hook_installation_target_id, + ]); + return response('Invalid signature.'); } } @@ -311,6 +339,17 @@ public function normal(Request $request) if ($result['status'] === 'queue_full') { return response($result['message'], 429)->header('Retry-After', 60); } + if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) { + auditLog('webhook.deployment.queued', [ + 'provider' => 'github', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + 'commit' => data_get($payload, 'after'), + 'github_app_id' => $github_app->id, + ]); + } $return_payloads->push([ 'status' => $result['status'], 'message' => $result['message'], diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 4453a0e7a..99ee4e477 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -32,6 +32,9 @@ public function manual(Request $request) } if (empty($x_gitlab_token)) { + auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'status' => 'failed', 'message' => 'Invalid signature.', @@ -101,6 +104,12 @@ public function manual(Request $request) foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); if (empty($webhook_secret)) { + auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -110,6 +119,12 @@ public function manual(Request $request) continue; } if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { + auditLogWebhookFailure('gitlab', 'invalid_signature', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'repository' => $full_name ?? null, + 'event' => $x_gitlab_event, + ]); $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -150,6 +165,15 @@ public function manual(Request $request) 'application_name' => $application->name, ]); } else { + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitlab', + 'mode' => 'manual', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + 'repository' => $full_name ?? null, + ]); $return_payloads->push([ 'status' => 'success', 'message' => 'Deployment queued.', diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index d59adf0ca..41e70b2ce 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,6 +6,8 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; +use Stripe\Exception\SignatureVerificationException; +use Stripe\Webhook; class Stripe extends Controller { @@ -14,7 +16,7 @@ public function events(Request $request) try { $webhookSecret = config('subscription.stripe_webhook_secret'); $signature = $request->header('Stripe-Signature'); - $event = \Stripe\Webhook::constructEvent( + $event = Webhook::constructEvent( $request->getContent(), $signature, $webhookSecret @@ -22,6 +24,12 @@ public function events(Request $request) StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); + } catch (SignatureVerificationException $e) { + auditLogWebhookFailure('stripe', 'invalid_signature', [ + 'error' => $e->getMessage(), + ]); + + return response($e->getMessage(), 400); } catch (Exception $e) { return response($e->getMessage(), 400); } diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 324eeebaa..f81c7d184 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use Illuminate\Auth\AuthenticationException; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility @@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities) } return parent::handle($request, $next, ...$abilities); - } catch (\Illuminate\Auth\AuthenticationException $e) { + } catch (AuthenticationException $e) { + auditLog('api.auth.unauthenticated', [ + 'reason' => $e->getMessage(), + 'required_abilities' => $abilities, + ], 'warning'); + return response()->json([ 'message' => 'Unauthenticated.', ], 401); } catch (\Exception $e) { + auditLog('api.auth.ability_denied', [ + 'required_abilities' => $abilities, + 'token_id' => $request->user()?->currentAccessToken()?->id, + 'reason' => $e->getMessage(), + ], 'warning'); + return response()->json([ 'message' => 'Missing required permissions: '.implode(', ', $abilities), ], 403); diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php new file mode 100644 index 000000000..8477450c4 --- /dev/null +++ b/bootstrap/helpers/audit.php @@ -0,0 +1,81 @@ + $context Identifiers + outcome details. + * @param string $level Log level: info | warning | error. + */ + function auditLog(string $event, array $context = [], string $level = 'info'): void + { + try { + $request = app()->bound('request') ? request() : null; + $user = auth()->check() ? auth()->user() : null; + $token = $user?->currentAccessToken(); + + $base = [ + 'event' => $event, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'team_id' => $token ? data_get($token, 'team_id') : null, + 'token_id' => $token?->id ?? null, + 'token_name' => $token?->name ?? null, + 'method' => $request?->method(), + 'path' => $request?->path(), + ]; + + $payload = array_merge($base, $context); + + Log::channel('audit')->{$level}($event, $payload); + } catch (Throwable $e) { + // Audit logging must never break the request path. + try { + Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); + } catch (Throwable) { + } + } + } +} + +if (! function_exists('auditLogWebhookFailure')) { + /** + * Record a webhook signature/auth verification failure to the `audit` channel. + */ + function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void + { + try { + $request = app()->bound('request') ? request() : null; + + $event = "webhook.{$provider}.signature_failed"; + + $base = [ + 'event' => $event, + 'reason' => $reason, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'method' => $request?->method(), + 'path' => $request?->path(), + 'event_header' => $request?->header('X-GitHub-Event') + ?? $request?->header('X-Gitlab-Event') + ?? $request?->header('X-Gitea-Event') + ?? $request?->header('X-Event-Key'), + ]; + + Log::channel('audit')->warning($event, array_merge($base, $context)); + } catch (Throwable $e) { + try { + Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); + } catch (Throwable) { + } + } + } +} diff --git a/config/logging.php b/config/logging.php index 1dbb1135f..05cf8e13d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -132,6 +132,14 @@ 'level' => 'warning', 'days' => 14, ], + + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_AUDIT_LEVEL', 'info'), + 'days' => env('LOG_AUDIT_DAYS', 90), + 'replace_placeholders' => true, + ], ], ]; diff --git a/openapi.json b/openapi.json index d83b30d80..09232be2f 100644 --- a/openapi.json +++ b/openapi.json @@ -4381,8 +4381,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4393,8 +4393,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4951,7 +4951,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4963,7 +4963,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index aab408098..d6a8d7635 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/routes/api.php b/routes/api.php index 7394d4e16..c944a6ebc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -215,6 +215,8 @@ Route::post('/sentinel/push', function () { $token = request()->header('Authorization'); if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + return response()->json(['message' => 'Unauthorized'], 401); } $naked_token = str_replace('Bearer ', '', $token); @@ -222,26 +224,49 @@ $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + return response()->json(['message' => 'Invalid token'], 401); } $server = Server::where('uuid', $server_uuid)->first(); if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + return response()->json(['message' => 'Server not found'], 404); } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Server is not functional'], 401); } if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } $data = request()->all(); @@ -249,6 +274,11 @@ // \App\Jobs\ServerCheckNewJob::dispatch($server, $data); PushServerUpdateJob::dispatch($server, $data); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'ok'], 200); }); }); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php new file mode 100644 index 000000000..34e9168ec --- /dev/null +++ b/tests/Feature/Security/AuditLogTest.php @@ -0,0 +1,445 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$team, $user]; +} + +function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string +{ + $token = $user->createToken('audit-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeAuditApplication(string $repo = 'test-org/test-repo'): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'audit-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +describe('audit channel helper', function () { + test('auditLog writes structured payload to audit channel', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('test.event', Mockery::on(function ($context) { + return $context['event'] === 'test.event' + && $context['custom_field'] === 'value' + && array_key_exists('ip', $context) + && array_key_exists('user_id', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.event', ['custom_field' => 'value']); + }); + + test('auditLog warning level routes correctly', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.failed', [], 'warning'); + }); + + test('auditLogWebhookFailure logs warning with provider tag', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->once() + ->with('webhook.github.signature_failed', Mockery::on(function ($context) { + return $context['reason'] === 'invalid_signature' + && $context['event'] === 'webhook.github.signature_failed' + && array_key_exists('ip', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']); + }); + + test('auditLog never includes raw secret keys in context', function () { + $captured = null; + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) { + $captured = $context; + + return true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.private_key.created', [ + 'team_id' => '1', + 'private_key_uuid' => 'abc', + 'fingerprint' => 'SHA256:xyz', + ]); + + expect($captured)->toBeArray(); + // Helper itself never injects secret-bearing keys. + $disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret']; + foreach (array_keys($captured) as $key) { + expect(in_array(strtolower($key), $disallowed, true))->toBeFalse(); + } + }); +}); + +describe('webhook signature failure logging', function () { + test('GitHub manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.github.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('GitLab manual webhook with bad token logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitlab.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Bitbucket manual webhook with malformed signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.bitbucket.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1=anyvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Gitea manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitea.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); +}); + +describe('API mutation audit logging', function () { + test('private key creation emits api.private_key.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.private_key.created', Mockery::on(function ($context) { + return $context['event'] === 'api.private_key.created' + && ! array_key_exists('private_key', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + // Generate a valid OpenSSH-format private key for the test. + $opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n". + base64_encode(str_repeat('a', 256)). + "\n-----END OPENSSH PRIVATE KEY-----"; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/security/keys', [ + 'name' => 'test-key', + 'description' => 'audit test', + 'private_key' => $opensshKey, + ]); + + // Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201. + expect($response->status())->toBeIn([201, 422]); + }); + + test('enable_api denial for non-root team emits warning audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.instance.enable_denied', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/enable'); + + $response->assertStatus(403); + }); + + test('project creation emits api.project.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.project.created', Mockery::on(function ($context) { + return $context['event'] === 'api.project.created' + && ! empty($context['project_uuid']) + && $context['project_name'] === 'audit-project'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'audit-project', + 'description' => 'audit', + ]); + + $response->assertStatus(201); + }); +}); + +describe('threat-detection audit logging (Phase 2)', function () { + test('missing bearer token logs api.auth.unauthenticated', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('expired bearer token logs api.auth.unauthenticated', function () { + [$team, $user] = makeAuditTeamUser(); + $token = $user->createToken('expired-audit', ['read'], now()->subDay()); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('read-only token hitting write endpoint logs api.auth.ability_denied', function () { + [$team, $user] = makeAuditTeamUser(); + $readToken = makeAuditApiToken($user, $team, ['read']); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.ability_denied', Mockery::on(function ($ctx) { + return in_array('write', $ctx['required_abilities'] ?? [], true); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'should-fail', + ]); + + $response->assertStatus(403); + }); + + test('sentinel push without Authorization logs token_missing', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'token_missing'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); + + test('sentinel push with un-decryptable bearer logs decrypt_failed', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'decrypt_failed'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer not-a-valid-encrypted-payload', + ])->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); +}); From cf13d4017888099ae969f5ee64ec662633f6118f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:27:50 +0200 Subject: [PATCH 023/100] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index f2f6946fb..8ebf27eed 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0', + 'version' => '4.0.1', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 3307b7f2e..fe7f6418d 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.13" + "version": "1.0.14" }, "sentinel": { "version": "0.0.21" diff --git a/versions.json b/versions.json index ef92cfe9e..fe7f6418d 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "nightly": { "version": "4.0.0" From b8226be774eb2fc5cc735d11bd85e250347a7ac4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:28:22 +0200 Subject: [PATCH 024/100] refactor(server): dispatch event for reachability notifications, drop retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move reachability notification triggering out of isReachableChanged into a dedicated ServerReachabilityChanged event dispatched by ServerConnectionCheckJob. Remove the blocking 3-attempt sleep loop from isReachableChanged — unreachable_count threshold alone now gates the Unreachable notification. Add feature and unit tests covering all notification dispatch paths. --- app/Jobs/ServerConnectionCheckJob.php | 34 ++++++ app/Models/Server.php | 26 +---- .../ServerReachabilityNotificationTest.php | 105 ++++++++++++++++++ tests/Unit/IsReachableChangedTest.php | 61 ++++++++++ tests/Unit/ServerBackoffTest.php | 51 +++++++++ 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 tests/Feature/ServerReachabilityNotificationTest.php create mode 100644 tests/Unit/IsReachableChangedTest.php diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 7ce316dcd..98ad60fff 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ServerReachabilityChanged; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; @@ -43,6 +44,9 @@ private function disableSshMux(): void public function handle() { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + try { // Check if server is disabled if ($this->server->settings->force_disabled) { @@ -84,6 +88,8 @@ public function handle() 'server_ip' => $this->server->ip, ]); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } @@ -99,6 +105,8 @@ public function handle() $this->server->update(['unreachable_count' => 0]); } + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); + } catch (\Throwable $e) { Log::error('ServerConnectionCheckJob failed', [ @@ -111,6 +119,8 @@ public function handle() ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + return; } } @@ -118,17 +128,41 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof TimeoutExceededException) { + $wasReachable = (bool) $this->server->settings->is_reachable; + $wasNotified = (bool) $this->server->unreachable_notification_sent; + $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, ]); $this->server->increment('unreachable_count'); + $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); + // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); } } + /** + * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2) + * or when a previously-notified server recovers. Skips noise from single transient flaps. + */ + private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void + { + if ($isReachable) { + if (! $wasReachable || $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + + return; + } + + if ($this->server->unreachable_count >= 2 && ! $wasNotified) { + ServerReachabilityChanged::dispatch($this->server); + } + } + private function checkHetznerStatus(): void { $status = null; diff --git a/app/Models/Server.php b/app/Models/Server.php index 06426f211..74e8ba5b0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1236,10 +1236,8 @@ public function isReachableChanged() $this->refresh(); $unreachableNotificationSent = (bool) $this->unreachable_notification_sent; $isReachable = (bool) $this->settings->is_reachable; - if ($isReachable === true) { - $this->unreachable_count = 0; - $this->save(); + if ($isReachable === true) { if ($unreachableNotificationSent === true) { $this->sendReachableNotification(); } @@ -1247,28 +1245,8 @@ public function isReachableChanged() return; } - $this->increment('unreachable_count'); - - if ($this->unreachable_count === 1) { - $this->settings->is_reachable = true; - $this->settings->save(); - - return; - } - if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) { - $failedChecks = 0; - for ($i = 0; $i < 3; $i++) { - $status = $this->serverStatus(); - sleep(5); - if (! $status) { - $failedChecks++; - } - } - - if ($failedChecks === 3 && ! $unreachableNotificationSent) { - $this->sendUnreachableNotification(); - } + $this->sendUnreachableNotification(); } } diff --git a/tests/Feature/ServerReachabilityNotificationTest.php b/tests/Feature/ServerReachabilityNotificationTest.php new file mode 100644 index 000000000..e996ba028 --- /dev/null +++ b/tests/Feature/ServerReachabilityNotificationTest.php @@ -0,0 +1,105 @@ +team = Team::factory()->create(); + $this->team->emailNotificationSettings()->update([ + 'use_instance_email_settings' => true, + 'server_unreachable_email_notifications' => true, + 'server_reachable_email_notifications' => true, + ]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + Notification::fake(); +}); + +it('sends Unreachable notification when threshold reached and not yet notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue(); +}); + +it('does not send Unreachable on first transient failure (count=1)', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 1, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('does not send Unreachable when already notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 5, + 'unreachable_notification_sent' => true, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('sends Reachable notification on recovery when previously notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => true, + ])->save(); + + $fresh = $this->server->fresh(); + expect($fresh->unreachable_notification_sent)->toBeTrue(); + expect((bool) $fresh->settings->is_reachable)->toBeTrue(); + + ServerReachabilityChanged::dispatch($fresh); + + Notification::assertSentTo($this->team, Reachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse(); +}); + +it('does not send Reachable when never notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('routes Unreachable notification through EmailChannel when email toggle is on', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) { + return in_array(EmailChannel::class, $channels); + }); +}); diff --git a/tests/Unit/IsReachableChangedTest.php b/tests/Unit/IsReachableChangedTest.php new file mode 100644 index 000000000..76f9863bf --- /dev/null +++ b/tests/Unit/IsReachableChangedTest.php @@ -0,0 +1,61 @@ +is_reachable = $isReachable; + + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('refresh')->andReturnSelf(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + + return $server; +} + +it('sends Reachable notification when reachable and notification was previously sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0); + $server->shouldReceive('sendReachableNotification')->once(); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send any notification when reachable and notification was never sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0); + $server->shouldNotReceive('sendReachableNotification'); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('sends Unreachable notification when count >= 2 and not yet notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2); + $server->shouldReceive('sendUnreachableNotification')->once(); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send Unreachable notification on first transient failure (count=1)', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not double-send Unreachable when already notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index bdcefb74f..9f1f747d4 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -1,11 +1,13 @@ is_reachable = true; $settings->shouldReceive('update') ->with(['is_reachable' => false, 'is_usable' => false]) ->once(); $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false); $server->shouldReceive('increment')->with('unreachable_count')->once(); $server->id = 1; $server->name = 'test-server'; + $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is $job = new ServerConnectionCheckJob($server); $job->failed(new TimeoutExceededException); @@ -152,6 +159,50 @@ }); }); +describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () { + // ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that + // call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy + // through the event dispatcher (which trips Eloquent static method lookups on the proxy class). + $invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) { + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + if ($expectDispatch) { + $server->shouldReceive('isReachableChanged')->once()->andReturnNull(); + } else { + $server->shouldNotReceive('isReachableChanged'); + } + + $job = new ServerConnectionCheckJob($server); + $method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded'); + $method->invoke($job, $wasReachable, $wasNotified, $isReachable); + }; + + it('dispatches event when count crosses unreachable threshold', function () use ($invoke) { + $invoke(true, false, false, 2, true); + }); + + it('does not dispatch on first transient failure (count=1)', function () use ($invoke) { + $invoke(true, false, false, 1, false); + }); + + it('does not dispatch when already notified and still unreachable', function () use ($invoke) { + $invoke(false, true, false, 5, false); + }); + + it('dispatches recovery event when previously unreachable', function () use ($invoke) { + $invoke(false, false, true, 0, true); + }); + + it('dispatches recovery event when previously notified', function () use ($invoke) { + $invoke(true, true, true, 0, true); + }); + + it('does not dispatch when consistently reachable and never notified', function () use ($invoke) { + $invoke(true, false, true, 0, false); + }); +}); + describe('ServerCheckJob unreachable_count', function () { it('increments unreachable_count on timeout', function () { $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); From 6293b14586b1dd7e23de9a121963a4ff22ea4eb6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:39:36 +0200 Subject: [PATCH 025/100] feat(server): add configurable SSH connection timeout per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `connection_timeout` field to server settings, allowing per-server override of the global SSH connection timeout constant. - Migration adds `connection_timeout` integer column (default 10s) - `ServerSetting` model exposes and casts the new field - `SshMultiplexingHelper::getConnectionTimeout()` resolves per-server value with fallback to `constants.ssh.connection_timeout` - All SSH/SCP command builders use the new resolver instead of the global config directly - Livewire `Show` component binds `connectionTimeout` with validation (1–300 seconds) and syncs to/from the model - UI input added to server settings form with helper text - Feature tests cover default, persistence, resolver, and fallback --- app/Helpers/SshMultiplexingHelper.php | 15 +++++-- app/Livewire/Server/Show.php | 13 +++++- app/Models/ServerSetting.php | 3 ++ ..._connection_timeout_to_server_settings.php | 22 ++++++++++ openapi.json | 4 ++ openapi.yaml | 3 ++ .../views/livewire/server/show.blade.php | 6 +++ tests/Feature/ServerConnectionTimeoutTest.php | 43 +++++++++++++++++++ 8 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php create mode 100644 tests/Feature/ServerConnectionTimeoutTest.php diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index aa9d06996..4629df571 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $sshConfig = self::serverSshConfiguration($server); $sshKeyLocation = $sshConfig['sshKeyLocation']; $muxSocket = $sshConfig['muxFilename']; - $connectionTimeout = config('constants.ssh.connection_timeout'); + $connectionTimeout = self::getConnectionTimeout($server); $serverInterval = config('constants.ssh.server_interval'); $muxPersistTime = config('constants.ssh.mux_persist_time'); @@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { @@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; } - $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval')); $delimiter = Hash::make($command); $delimiter = base64_encode($delimiter); @@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void } } + public static function getConnectionTimeout(Server $server): int + { + $timeout = data_get($server, 'settings.connection_timeout'); + + return is_numeric($timeout) && (int) $timeout > 0 + ? (int) $timeout + : (int) config('constants.ssh.connection_timeout'); + } + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string { $options = "-i {$sshKeyLocation} " diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 84cb65ee6..3e05d9306 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -32,6 +32,8 @@ class Show extends Component public string $port; + public int $connectionTimeout; + public ?string $validationLogs = null; public ?string $wildcardDomain = null; @@ -110,6 +112,7 @@ protected function rules(): array 'ip' => ['required', new ValidServerIp], 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', + 'connectionTimeout' => 'required|integer|min:1|max:300', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -138,6 +141,10 @@ protected function messages(): array 'ip.required' => 'The IP Address field is required.', 'user.required' => 'The User field is required.', 'port.required' => 'The Port field is required.', + 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.', + 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.', + 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.', + 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.', 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', 'sentinelToken.required' => 'The Sentinel Token field is required.', 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', @@ -210,6 +217,7 @@ public function syncData(bool $toModel = false) $this->server->validation_logs = $this->validationLogs; $this->server->save(); + $this->server->settings->connection_timeout = $this->connectionTimeout; $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; @@ -237,6 +245,7 @@ public function syncData(bool $toModel = false) $this->ip = $this->server->ip; $this->user = $this->server->user; $this->port = $this->server->port; + $this->connectionTimeout = $this->server->settings->connection_timeout; $this->wildcardDomain = $this->server->settings->wildcard_domain; $this->isReachable = $this->server->settings->is_reachable; @@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false) return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $this->hetznerServerStatus = $serverData['status'] ?? null; @@ -471,7 +480,7 @@ public function startHetznerServer() return; } - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $hetznerService->powerOnServer($this->server->hetzner_server_id); $this->hetznerServerStatus = 'starting'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 30fc1e165..8d85c8932 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -49,6 +49,7 @@ 'updated_at' => ['type' => 'string'], 'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'], 'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'], ] )] class ServerSetting extends Model @@ -97,6 +98,7 @@ class ServerSetting extends Model 'is_terminal_enabled', 'deployment_queue_limit', 'disable_application_image_retention', + 'connection_timeout', ]; protected $casts = [ @@ -108,6 +110,7 @@ class ServerSetting extends Model 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', + 'connection_timeout' => 'integer', ]; protected static function booted() diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php new file mode 100644 index 000000000..1700feebc --- /dev/null +++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php @@ -0,0 +1,22 @@ +integer('connection_timeout')->default(10)->after('deployment_queue_limit'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('connection_timeout'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 09232be2f..8b3d5b91f 100644 --- a/openapi.json +++ b/openapi.json @@ -13349,6 +13349,10 @@ "delete_unused_networks": { "type": "boolean", "description": "The flag to indicate if the unused networks should be deleted." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index d6a8d7635..07d9b6095 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8538,6 +8538,9 @@ components: delete_unused_networks: type: boolean description: 'The flag to indicate if the unused networks should be deleted.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds.' type: object Service: description: 'Service model' diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d694174d5..cfbeccd0c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 label="Port" required :disabled="$isValidating" />
+
+ +
diff --git a/tests/Feature/ServerConnectionTimeoutTest.php b/tests/Feature/ServerConnectionTimeoutTest.php new file mode 100644 index 000000000..b457f3f01 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutTest.php @@ -0,0 +1,43 @@ +create(); + $this->team = $user->teams()->first(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('defaults connection_timeout to 10 seconds for new servers', function () { + expect($this->server->settings->connection_timeout)->toBe(10); +}); + +it('persists a custom connection_timeout value', function () { + $this->server->settings->connection_timeout = 30; + $this->server->settings->save(); + + expect($this->server->settings->fresh()->connection_timeout)->toBe(30); +}); + +it('returns the per-server connection_timeout from getConnectionTimeout', function () { + $this->server->settings->connection_timeout = 45; + $this->server->settings->save(); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45); +}); + +it('falls back to config default when connection_timeout is invalid', function () { + $this->server->settings->connection_timeout = 0; + $this->server->settings->saveQuietly(); + + $expected = (int) config('constants.ssh.connection_timeout'); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected); +}); From 4a2e37e87fd66540e87dba7fada0083f9840fbe0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:04 +0200 Subject: [PATCH 026/100] chore(dev): replace minio image with maxio:latest in docker-compose.dev --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f608fe3cb..82b7edb44 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + image: ghcr.io/coollabsio/maxio:latest pull_policy: always container_name: coolify-minio command: server /data --console-address ":9001" From 9bb819c33ea6d327377038717d402851e9697726 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:43:58 +0200 Subject: [PATCH 027/100] feat(api): expose connection_timeout in servers API Add connection_timeout to create_server docs, update_server allowed fields, validation (integer 1-300), and advanced settings update path. --- app/Http/Controllers/Api/ServersController.php | 6 ++++-- openapi.json | 4 ++++ openapi.yaml | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index b33e85c78..6c3b2da00 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -612,6 +612,7 @@ public function create_server(Request $request) 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], + 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'], ], ), ), @@ -648,7 +649,7 @@ public function create_server(Request $request) )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -674,6 +675,7 @@ public function update_server(Request $request) 'deployment_queue_limit' => 'integer|min:1', 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_check_frequency' => 'string', + 'connection_timeout' => 'integer|min:1|max:300', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -718,7 +720,7 @@ public function update_server(Request $request) ], 422); } - $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } diff --git a/openapi.json b/openapi.json index 8b3d5b91f..059b3d911 100644 --- a/openapi.json +++ b/openapi.json @@ -10545,6 +10545,10 @@ "server_disk_usage_check_frequency": { "type": "string", "description": "Cron expression for disk usage check frequency." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds (1-300). Default: 10." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 07d9b6095..83aa30744 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6734,6 +6734,9 @@ paths: server_disk_usage_check_frequency: type: string description: 'Cron expression for disk usage check frequency.' + connection_timeout: + type: integer + description: 'SSH connection timeout in seconds (1-300). Default: 10.' type: object responses: '201': From e8dc48e058e6c21df89b0262e3fdbd47d60db059 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:20 +0200 Subject: [PATCH 028/100] fix(vite): make dev server host/port configurable via env vars Replace hardcoded HMR host with VITE_HOST/VITE_PORT env vars. Set allowedHosts to true and derive origin/HMR config from env, falling back to defaults when vars are absent. --- vite.config.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/vite.config.js b/vite.config.js index fc739c95d..4b967c40e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const viteHost = env.VITE_HOST || null; + const vitePort = Number(env.VITE_PORT || 5173); return { server: { @@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => { ], }, host: "0.0.0.0", - hmr: { - host: env.VITE_HOST || '0.0.0.0' - }, + allowedHosts: true, + origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined, + hmr: viteHost + ? { host: viteHost, clientPort: vitePort } + : true, }, plugins: [ laravel({ From 19994a0a135ab06cd52e4869cfa5aeffd9455a8e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:20:15 +0200 Subject: [PATCH 029/100] test(api): add feature tests for server connection_timeout API Tests cover PATCH update success, out-of-range, above-max, and non-integer validation for the connection_timeout field. --- .../ServerConnectionTimeoutApiTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/Feature/ServerConnectionTimeoutApiTest.php diff --git a/tests/Feature/ServerConnectionTimeoutApiTest.php b/tests/Feature/ServerConnectionTimeoutApiTest.php new file mode 100644 index 000000000..287122523 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutApiTest.php @@ -0,0 +1,74 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $newToken = $this->user->createToken('write-token', ['write']); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $newToken->plainTextToken; +}); + +it('PATCH updates connection_timeout via API', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 45, + ]); + + $response->assertStatus(201); + expect($this->server->settings->fresh()->connection_timeout)->toBe(45); +}); + +it('PATCH rejects connection_timeout out of range', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 0, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects connection_timeout above max', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 999, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects non-integer connection_timeout', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 'fast', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); From eaaf258f25ea1a793885e3027072b49543dd2ac3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:36:56 +0200 Subject: [PATCH 030/100] fix(service): block UI editing of file volumes exceeding 5 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large host files mounted via Docker volumes caused the storages page to become unusable — full file content was stored in the encrypted mediumText column and serialised into the Livewire payload, crashing the browser. - Add MAX_CONTENT_SIZE (5 MiB), BINARY_PLACEHOLDER, and TOO_LARGE_PLACEHOLDER constants to LocalFileVolume - Check remote file size via stat/wc before cat in loadStorageOnServer and saveStorageOnServer; store placeholder instead of content when limit exceeded - Expose is_too_large computed attribute (appended for Livewire serialisation) - Guard submit, instantSave, and syncData in FileStorage Livewire component - Truncate oversized content in Storage::refreshStorages to prevent payload bloat - Show distinct warning banner in file-storage blade; mark textarea readonly and hide Save/Convert buttons for too-large files - Add unit tests covering constants, computed flags, and toArray serialisation Fixes #4701 --- app/Livewire/Project/Service/FileStorage.php | 16 ++++- app/Livewire/Project/Service/Storage.php | 6 +- app/Models/LocalFileVolume.php | 47 +++++++++++-- .../project/service/file-storage.blade.php | 12 ++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 66 +++++++++++++++++++ 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/LocalFileVolumeContentSizeTest.php diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 844e37854..2f1a229b4 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -63,13 +63,16 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large; $this->syncData(); } public function syncData(bool $toModel = false): void { if ($toModel) { + if ($this->fileStorage->is_too_large) { + return; + } $this->validate(); // Sync to model @@ -172,6 +175,12 @@ public function submit() { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } + $original = $this->fileStorage->getOriginal(); try { $this->validate(); @@ -197,6 +206,11 @@ public function submit() public function instantSave(): void { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_too_large) { + $this->dispatch('error', 'File on server is too large to edit from the UI.'); + + return; + } $this->syncData(true); $this->dispatch('success', 'File updated.'); } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6f43662d5..30655691a 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -69,7 +69,11 @@ public function refreshStoragesFromEvent() public function refreshStorages() { - $this->fileStorage = $this->resource->fileStorages()->get(); + $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) { + if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) { + $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + } + }); $this->resource->load('persistentStorages.resource'); } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4b5c602c2..627750232 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -10,6 +10,12 @@ class LocalFileVolume extends BaseModel { + public const MAX_CONTENT_SIZE = 5_242_880; + + public const BINARY_PLACEHOLDER = '[binary file]'; + + public const TOO_LARGE_PLACEHOLDER = '[file too large to display]'; + protected $casts = [ // 'fs_path' => 'encrypted', // 'mount_path' => 'encrypted', @@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled', ]; - public $appends = ['is_binary']; + public $appends = ['is_binary', 'is_too_large']; protected static function booted() { @@ -46,9 +52,14 @@ protected static function booted() protected function isBinary(): Attribute { return Attribute::make( - get: function () { - return $this->content === '[binary file]'; - } + get: fn () => $this->content === self::BINARY_PLACEHOLDER + ); + } + + protected function isTooLarge(): Attribute + { + return Attribute::make( + get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER ); } @@ -81,10 +92,17 @@ public function loadStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + $this->is_directory = false; + $this->save(); + + return; + } $content = instant_remote_process(["cat {$escapedPath}"], $server, false); // Check if content contains binary data by looking for null bytes or non-printable characters if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { - $content = '[binary file]'; + $content = self::BINARY_PLACEHOLDER; } $this->content = $content; $this->is_directory = false; @@ -92,6 +110,18 @@ public function loadStorageOnServer() } } + protected function remoteFileExceedsLimit(string $escapedPath, $server): bool + { + $sizeOutput = instant_remote_process( + ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"], + $server, + false, + ); + $size = (int) trim((string) $sizeOutput); + + return $size > self::MAX_CONTENT_SIZE; + } + public function deleteStorageOnServer() { $this->load(['service']); @@ -173,9 +203,12 @@ public function saveStorageOnServer() $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK' && $this->is_directory) { - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + if ($this->remoteFileExceedsLimit($escapedPath, $server)) { + $this->content = self::TOO_LARGE_PLACEHOLDER; + } else { + $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + } $this->is_directory = false; - $this->content = $content; $this->save(); FileStorageChanged::dispatch(data_get($server, 'team_id')); throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.'); diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 48eb935ab..b7f68c6ec 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -1,6 +1,10 @@
- @if ($isReadOnly) + @if ($fileStorage->is_too_large) +
+ File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server. +
+ @elseif ($isReadOnly)
@if ($fileStorage->is_directory) This directory is mounted as read-only and cannot be modified from the UI. @@ -44,7 +48,7 @@ confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" shortConfirmationLabel="Filepath" /> @else - @if (!$fileStorage->is_binary) + @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version." rows="20" id="content" - readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"> - @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) + readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"> + @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large) Save @endif @else diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php new file mode 100644 index 000000000..1fd315884 --- /dev/null +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -0,0 +1,66 @@ +toBe(5_242_880); +}); + +it('exposes binary and too-large placeholder constants', function () { + expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]'); + expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]'); +}); + +it('flags is_too_large when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + expect($volume->is_too_large)->toBeTrue(); + expect($volume->is_binary)->toBeFalse(); +}); + +it('flags is_binary when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::BINARY_PLACEHOLDER; + + expect($volume->is_binary)->toBeTrue(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag normal content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = "hello\nworld\n"; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag empty content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = null; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('exposes the too-large flag via toArray for Livewire serialization', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + $array = $volume->toArray(); + + expect($array)->toHaveKey('is_too_large'); + expect($array['is_too_large'])->toBeTrue(); +}); From 33f5cbb7d7bf7bf37dfc98d1ddd79cb69753a30a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:58:45 +0200 Subject: [PATCH 031/100] chore(version): bump version to 4.1.0 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 8ebf27eed..867cc22d9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.1', + 'version' => '4.1.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index fe7f6418d..f8b4ea890 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index fe7f6418d..f8b4ea890 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.1" + "version": "4.1.0" }, "nightly": { "version": "4.0.0" From 46180dbbf9e2d55f8b6a57d417197210cf4f3671 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:12:24 +0200 Subject: [PATCH 032/100] feat(webhook): skip deployment on [skip ci]/[skip cd] commit markers Add DetectsSkipDeployCommits trait with two strategies: shouldSkipDeploy (all commits must contain the marker) for push events, and shouldSkipDeployAny (any single marker triggers skip) for PR/MR titles and latest-commit signals. Apply trait to Bitbucket, Gitea, GitHub, GitLab webhook controllers and ProcessGithubPullRequestWebhook job. PRs pass pullRequestTitle through to the job constructor for evaluation. --- app/Http/Controllers/Webhook/Bitbucket.php | 35 ++++++ .../Concerns/DetectsSkipDeployCommits.php | 55 +++++++++ app/Http/Controllers/Webhook/Gitea.php | 26 ++++ app/Http/Controllers/Webhook/Github.php | 31 +++++ app/Http/Controllers/Webhook/Gitlab.php | 27 ++++ app/Jobs/ProcessGithubPullRequestWebhook.php | 7 ++ docker-compose.dev.yml | 1 - tests/Unit/DetectsSkipDeployCommitsTest.php | 115 ++++++++++++++++++ 8 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php create mode 100644 tests/Unit/DetectsSkipDeployCommitsTest.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index eda2014a6..ee7f25431 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -12,6 +13,8 @@ class Bitbucket extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -31,6 +34,16 @@ public function manual(Request $request) $branch = data_get($payload, 'push.changes.0.new.name'); $full_name = data_get($payload, 'repository.full_name'); $commit = data_get($payload, 'push.changes.0.new.target.hash'); + // Bitbucket webhooks ship up to 5 commits per change. Larger pushes + // are evaluated only on the visible 5. + $skip_deploy_commits = self::shouldSkipDeploy( + collect(data_get($payload, 'push.changes', [])) + ->flatMap(fn ($change) => data_get($change, 'commits', [])) + ->pluck('message') + ->filter() + ->values() + ->all() + ); if (! $branch) { return response([ @@ -45,6 +58,8 @@ public function manual(Request $request) $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'pullrequest.id'); $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); + $pull_request_title = data_get($payload, 'pullrequest.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } $applications = Application::where('git_repository', 'like', "%$full_name%"); @@ -119,6 +134,17 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'repo:push') { if ($application->isDeployable()) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -161,6 +187,15 @@ public function manual(Request $request) } if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php new file mode 100644 index 000000000..69695e99b --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php @@ -0,0 +1,55 @@ + $messages + */ + public static function shouldSkipDeploy(array $messages): bool + { + $messages = array_values(array_filter($messages, fn ($m) => filled($m))); + + if (empty($messages)) { + return false; + } + + foreach ($messages as $message) { + $lower = strtolower((string) $message); + if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) { + return false; + } + } + + return true; + } + + /** + * Returns true if at least one non-empty message contains [skip cd] or + * [skip ci]. Used for PR/MR title + latest-commit signals where any one + * marker should trigger the skip. + * + * @param array $messages + */ + public static function shouldSkipDeployAny(array $messages): bool + { + foreach ($messages as $message) { + if (! filled($message)) { + continue; + } + $lower = strtolower((string) $message); + if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index c6297905e..64807d694 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitea extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -40,12 +43,15 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitea_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); } @@ -112,6 +118,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -170,6 +187,15 @@ public function manual(Request $request) if ($x_gitea_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 7489b433f..b0e11f60c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -16,6 +17,8 @@ class Github extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -43,12 +46,14 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -126,6 +131,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -201,6 +217,7 @@ public function manual(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), @@ -274,12 +291,14 @@ public function normal(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); $id = data_get($payload, 'repository.id'); $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); + $pull_request_title = data_get($payload, 'pull_request.title'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); $before_sha = data_get($payload, 'before'); @@ -328,6 +347,17 @@ public function normal(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -399,6 +429,7 @@ public function normal(Request $request) action: $action, pullRequestId: $pull_request_id, pullRequestHtmlUrl: $pull_request_html_url, + pullRequestTitle: $pull_request_title ?? null, beforeSha: $before_sha, afterSha: $after_sha, commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 99ee4e477..205bede8f 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -13,6 +14,8 @@ class Gitlab extends Controller { + use DetectsSkipDeployCommits; + public function manual(Request $request) { try { @@ -61,6 +64,7 @@ public function manual(Request $request) $removed_files = data_get($payload, 'commits.*.removed'); $modified_files = data_get($payload, 'commits.*.modified'); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); } if ($x_gitlab_event === 'merge_request') { $action = data_get($payload, 'object_attributes.action'); @@ -69,6 +73,9 @@ public function manual(Request $request) $full_name = data_get($payload, 'project.path_with_namespace'); $pull_request_id = data_get($payload, 'object_attributes.iid'); $pull_request_html_url = data_get($payload, 'object_attributes.url'); + $pull_request_title = data_get($payload, 'object_attributes.title'); + $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); if (! $branch) { $return_payloads->push([ 'status' => 'failed', @@ -147,6 +154,17 @@ public function manual(Request $request) if ($application->isDeployable()) { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($skip_deploy_commits ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + continue; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, @@ -206,6 +224,15 @@ public function manual(Request $request) if ($x_gitlab_event === 'merge_request') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($application->isPRDeployable()) { + if ($skip_deploy_pr ?? false) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.', + ]); + + continue; + } $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 041cd812c..54e386676 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; +use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\GithubApp; @@ -17,6 +18,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue { + use DetectsSkipDeployCommits; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; @@ -31,6 +33,7 @@ public function __construct( public string $action, public int $pullRequestId, public string $pullRequestHtmlUrl, + public ?string $pullRequestTitle, public ?string $beforeSha, public ?string $afterSha, public string $commitSha, @@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp return; } + if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { + return; + } + // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 82b7edb44..50edc140f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -132,7 +132,6 @@ services: image: ghcr.io/coollabsio/maxio:latest pull_policy: always container_name: coolify-minio - command: server /data --console-address ":9001" ports: - "${FORWARD_MINIO_PORT:-9000}:9000" - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" diff --git a/tests/Unit/DetectsSkipDeployCommitsTest.php b/tests/Unit/DetectsSkipDeployCommitsTest.php new file mode 100644 index 000000000..5255df5d6 --- /dev/null +++ b/tests/Unit/DetectsSkipDeployCommitsTest.php @@ -0,0 +1,115 @@ +toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse(); + }); + + test('returns true when all messages contain [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Fix typo [skip ci]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns true when single message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue(); + }); + + test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) { + $messages = [ + 'Docs [SKIP CI]', + 'Changelog [Skip Cd]', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('returns false when at least one message has no skip marker', function () use ($harnessClass) { + $messages = [ + 'Update docs [skip ci]', + 'Actual feature change', + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse(); + }); + + test('returns false when single message has no skip marker', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse(); + }); + + test('null entries are filtered before evaluation', function () use ($harnessClass) { + $messages = [ + null, + 'Docs [skip ci]', + null, + ]; + expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue(); + }); + + test('matches PR title scenario (single string)', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse(); + expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse(); + }); +}); + +describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) { + test('returns false when messages array is empty', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse(); + }); + + test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('returns true when any one message contains [skip ci]', function () use ($harnessClass) { + $messages = [ + 'Real feature change', + 'docs: update readme [skip ci]', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue(); + }); + + test('returns true when any one message contains [skip cd]', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue(); + }); + + test('returns true case-insensitively', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue(); + }); + + test('returns false when no message contains a skip marker', function () use ($harnessClass) { + $messages = [ + 'feat: add new endpoint', + 'fix: handle edge case', + ]; + expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse(); + }); + + test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue(); + expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse(); + }); + + test('PR title alone with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue(); + }); + + test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) { + expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue(); + }); +}); From 7ab16ad7b5e34e0da37f91f4be4c7396bc97264a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:30:43 +0200 Subject: [PATCH 033/100] feat(mcp): add MCP server with read-only tools for Coolify resources Add Model Context Protocol server exposing Coolify infrastructure data to AI assistants. Includes tools for listing/fetching servers, projects, applications, databases, and services, scoped to authenticated team tokens. - Add CoolifyServer with 10 read-only tools (list/get for all resource types) - Add BuildsResponse and ResolvesTeam traits for shared tool logic - Add EnsureMcpEnabled middleware guarding /mcp routes - Add enable/disable MCP API endpoints (root-only) - Add is_mcp_server_enabled toggle in instance settings and advanced UI - Add migration for is_mcp_server_enabled column - Add feature tests for MCP endpoints and toggle API - Scrub sensitive keys (passwords, tokens, raw IDs) from all responses --- app/Http/Controllers/Api/OtherController.php | 112 +++++++++ app/Http/Kernel.php | 102 +++++--- app/Http/Middleware/EnsureMcpEnabled.php | 25 ++ app/Livewire/Settings/Advanced.php | 6 + app/Mcp/Concerns/BuildsResponse.php | 225 ++++++++++++++++++ app/Mcp/Concerns/ResolvesTeam.php | 35 +++ app/Mcp/Servers/CoolifyServer.php | 50 ++++ app/Mcp/Tools/GetApplication.php | 60 +++++ app/Mcp/Tools/GetDatabase.php | 58 +++++ app/Mcp/Tools/GetInfrastructureOverview.php | 93 ++++++++ app/Mcp/Tools/GetServer.php | 57 +++++ app/Mcp/Tools/GetService.php | 61 +++++ app/Mcp/Tools/ListApplications.php | 74 ++++++ app/Mcp/Tools/ListDatabases.php | 69 ++++++ app/Mcp/Tools/ListProjects.php | 66 +++++ app/Mcp/Tools/ListServers.php | 67 ++++++ app/Mcp/Tools/ListServices.php | 68 ++++++ app/Models/InstanceSettings.php | 2 + composer.json | 1 + composer.lock | 150 ++++++------ ...ver_enabled_to_instance_settings_table.php | 28 +++ openapi.json | 104 ++++++++ openapi.yaml | 58 +++++ .../livewire/settings/advanced.blade.php | 11 + routes/ai.php | 7 + routes/api.php | 2 + tests/Feature/Mcp/McpEndpointTest.php | 194 +++++++++++++++ tests/Feature/Mcp/McpToggleApiTest.php | 107 +++++++++ 28 files changed, 1783 insertions(+), 109 deletions(-) create mode 100644 app/Http/Middleware/EnsureMcpEnabled.php create mode 100644 app/Mcp/Concerns/BuildsResponse.php create mode 100644 app/Mcp/Concerns/ResolvesTeam.php create mode 100644 app/Mcp/Servers/CoolifyServer.php create mode 100644 app/Mcp/Tools/GetApplication.php create mode 100644 app/Mcp/Tools/GetDatabase.php create mode 100644 app/Mcp/Tools/GetInfrastructureOverview.php create mode 100644 app/Mcp/Tools/GetServer.php create mode 100644 app/Mcp/Tools/GetService.php create mode 100644 app/Mcp/Tools/ListApplications.php create mode 100644 app/Mcp/Tools/ListDatabases.php create mode 100644 app/Mcp/Tools/ListProjects.php create mode 100644 app/Mcp/Tools/ListServers.php create mode 100644 app/Mcp/Tools/ListServices.php create mode 100644 database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/McpEndpointTest.php create mode 100644 tests/Feature/Mcp/McpToggleApiTest.php diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 5ac274f93..17c5a884a 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -153,6 +153,118 @@ public function disable_api(Request $request) return response()->json(['message' => 'API disabled.'], 200); } + #[OA\Get( + summary: 'Enable MCP Server', + description: 'Enable the MCP server endpoint at /mcp (only with root permissions).', + path: '/mcp/enable', + operationId: 'enable-mcp', + security: [ + ['bearerAuth' => []], + ], + responses: [ + new OA\Response( + response: 200, + description: 'MCP server enabled.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'), + ] + )), + new OA\Response( + response: 403, + description: 'You are not allowed to enable the MCP server.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'), + ] + )), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function enable_mcp(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if ($teamId !== '0') { + auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning'); + + return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403); + } + $settings = instanceSettings(); + $settings->update(['is_mcp_server_enabled' => true]); + + auditLog('api.mcp.enabled', ['team_id' => $teamId]); + + return response()->json(['message' => 'MCP server enabled.'], 200); + } + + #[OA\Get( + summary: 'Disable MCP Server', + description: 'Disable the MCP server endpoint at /mcp (only with root permissions).', + path: '/mcp/disable', + operationId: 'disable-mcp', + security: [ + ['bearerAuth' => []], + ], + responses: [ + new OA\Response( + response: 200, + description: 'MCP server disabled.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'), + ] + )), + new OA\Response( + response: 403, + description: 'You are not allowed to disable the MCP server.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'), + ] + )), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function disable_mcp(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if ($teamId !== '0') { + auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning'); + + return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403); + } + $settings = instanceSettings(); + $settings->update(['is_mcp_server_enabled' => false]); + + auditLog('api.mcp.disabled', ['team_id' => $teamId]); + + return response()->json(['message' => 'MCP server disabled.'], 200); + } + public function feedback(Request $request) { $data = $request->validate([ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 515d40c62..a584bc111 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -2,7 +2,40 @@ namespace App\Http; +use App\Http\Middleware\ApiAbility; +use App\Http\Middleware\ApiSensitiveData; +use App\Http\Middleware\Authenticate; +use App\Http\Middleware\CanAccessTerminal; +use App\Http\Middleware\CanCreateResources; +use App\Http\Middleware\CanUpdateResource; +use App\Http\Middleware\CheckForcePasswordReset; +use App\Http\Middleware\DecideWhatToDoWithUser; +use App\Http\Middleware\EncryptCookies; +use App\Http\Middleware\EnsureMcpEnabled; +use App\Http\Middleware\PreventRequestsDuringMaintenance; +use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\TrimStrings; +use App\Http\Middleware\TrustHosts; +use App\Http\Middleware\TrustProxies; +use App\Http\Middleware\ValidateSignature; +use App\Http\Middleware\VerifyCsrfToken; +use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; +use Illuminate\Auth\Middleware\Authorize; +use Illuminate\Auth\Middleware\EnsureEmailIsVerified; +use Illuminate\Auth\Middleware\RequirePassword; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; +use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\ValidatePostSize; +use Illuminate\Http\Middleware\HandleCors; +use Illuminate\Http\Middleware\SetCacheHeaders; +use Illuminate\Routing\Middleware\SubstituteBindings; +use Illuminate\Routing\Middleware\ThrottleRequests; +use Illuminate\Session\Middleware\AuthenticateSession; +use Illuminate\Session\Middleware\StartSession; +use Illuminate\View\Middleware\ShareErrorsFromSession; +use Laravel\Sanctum\Http\Middleware\CheckAbilities; +use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class Kernel extends HttpKernel { @@ -14,13 +47,13 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - \App\Http\Middleware\TrustHosts::class, - \App\Http\Middleware\TrustProxies::class, - \Illuminate\Http\Middleware\HandleCors::class, - \App\Http\Middleware\PreventRequestsDuringMaintenance::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - \App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + TrustHosts::class, + TrustProxies::class, + HandleCors::class, + PreventRequestsDuringMaintenance::class, + ValidatePostSize::class, + TrimStrings::class, + ConvertEmptyStringsToNull::class, ]; @@ -31,21 +64,21 @@ class Kernel extends HttpKernel */ protected $middlewareGroups = [ 'web' => [ - \App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \App\Http\Middleware\CheckForcePasswordReset::class, - \App\Http\Middleware\DecideWhatToDoWithUser::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + CheckForcePasswordReset::class, + DecideWhatToDoWithUser::class, ], 'api' => [ // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, - \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, + ThrottleRequests::class.':api', + SubstituteBindings::class, ], ]; @@ -57,22 +90,23 @@ class Kernel extends HttpKernel * @var array */ protected $middlewareAliases = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, - 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'signed' => \App\Http\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, - 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, - 'api.ability' => \App\Http\Middleware\ApiAbility::class, - 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, - 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class, - 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class, - 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class, + 'auth' => Authenticate::class, + 'auth.basic' => AuthenticateWithBasicAuth::class, + 'auth.session' => AuthenticateSession::class, + 'cache.headers' => SetCacheHeaders::class, + 'can' => Authorize::class, + 'guest' => RedirectIfAuthenticated::class, + 'password.confirm' => RequirePassword::class, + 'signed' => ValidateSignature::class, + 'throttle' => ThrottleRequests::class, + 'verified' => EnsureEmailIsVerified::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + 'api.ability' => ApiAbility::class, + 'api.sensitive' => ApiSensitiveData::class, + 'can.create.resources' => CanCreateResources::class, + 'can.update.resource' => CanUpdateResource::class, + 'can.access.terminal' => CanAccessTerminal::class, + 'mcp.enabled' => EnsureMcpEnabled::class, ]; } diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php new file mode 100644 index 000000000..9c4f1339c --- /dev/null +++ b/app/Http/Middleware/EnsureMcpEnabled.php @@ -0,0 +1,25 @@ +is_mcp_server_enabled) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index d31f68859..3a6237183 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -37,6 +37,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_wire_navigate_enabled; + #[Validate('boolean')] + public bool $is_mcp_server_enabled; + public function rules() { return [ @@ -49,6 +52,7 @@ public function rules() 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', + 'is_mcp_server_enabled' => 'boolean', ]; } @@ -67,6 +71,7 @@ public function mount() $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; + $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; } public function submit() @@ -150,6 +155,7 @@ public function instantSave() $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; + $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php new file mode 100644 index 000000000..10d87ae92 --- /dev/null +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -0,0 +1,225 @@ + + */ + protected array $sensitiveKeys = [ + // raw IDs / morph types (uuid is the public identifier) + 'id', 'team_id', 'tokenable_id', 'tokenable_type', + 'server_id', 'private_key_id', 'cloud_provider_token_id', + 'hetzner_server_id', 'environment_id', 'destination_id', + 'source_id', 'repository_project_id', 'application_id', + 'service_id', 'project_id', 'parent_id', + 'resourceable', 'resourceable_id', 'resourceable_type', + 'destination_type', 'source_type', 'tokenable', + + // sentinel / observability secrets + 'sentinel_token', 'sentinel_custom_url', + 'logdrain_newrelic_license_key', 'logdrain_axiom_api_key', + 'logdrain_custom_config', 'logdrain_custom_config_parser', + + // database passwords + 'postgres_password', 'dragonfly_password', 'keydb_password', + 'redis_password', 'mongo_initdb_root_password', + 'mariadb_password', 'mariadb_root_password', + 'mysql_password', 'mysql_root_password', + 'clickhouse_admin_password', + + // app/env secrets + 'value', 'real_value', 'http_basic_auth_password', + + // database connection strings embed credentials + 'internal_db_url', 'external_db_url', 'init_scripts', + + // webhook secrets + 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', + 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', + + // bulky / unsafe blobs + 'dockerfile', 'docker_compose', 'docker_compose_raw', + 'custom_labels', 'environment_variables', + 'environment_variables_preview', 'validation_logs', + 'server_metadata', + ]; + + /** + * Recursively remove sensitive keys from any nested array structure. + * + * @param array $data + * @return array + */ + protected function scrubSensitive(array $data): array + { + $deny = array_flip($this->sensitiveKeys); + + $walk = function ($value) use (&$walk, $deny) { + if (! is_array($value)) { + return $value; + } + + $out = []; + foreach ($value as $key => $inner) { + if (is_string($key) && isset($deny[$key])) { + continue; + } + $out[$key] = $walk($inner); + } + + return $out; + }; + + return $walk($data); + } + + /** + * @param array|array $data + * @param array> $actions + * @param array|null $pagination + */ + protected function respond(array $data, array $actions = [], ?array $pagination = null): Response + { + $payload = ['data' => $data]; + + if ($actions !== []) { + $payload['_actions'] = $actions; + } + + if ($pagination !== null) { + $payload['_pagination'] = $pagination; + } + + return Response::json($payload); + } + + /** + * @return array{page:int, per_page:int, offset:int} + */ + protected function paginationArgs(Request $request): array + { + $page = max(1, (int) ($request->get('page') ?? 1)); + $perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage); + $perPage = max(1, min($this->maxPerPage, $perPage)); + + return [ + 'page' => $page, + 'per_page' => $perPage, + 'offset' => ($page - 1) * $perPage, + ]; + } + + /** + * @param array{page:int, per_page:int, offset:int} $args + * @return array|null + */ + protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array + { + $page = $args['page']; + $perPage = $args['per_page']; + $totalPages = (int) ceil($total / $perPage); + + $meta = [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + ]; + + if ($page < $totalPages) { + $meta['next'] = [ + 'tool' => $tool, + 'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]), + ]; + } + + return $meta; + } + + /** + * HATEOAS-style action suggestions for an application. + * + * @return array> + */ + protected function actionsForApplication(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForDatabase(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForService(string $uuid, ?string $status = null): array + { + $actions = [ + ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + + $s = strtolower((string) $status); + if (str_contains($s, 'running')) { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + } else { + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + } + + return $actions; + } + + /** + * @return array> + */ + protected function actionsForServer(string $uuid): array + { + return [ + ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php new file mode 100644 index 000000000..f75219fcf --- /dev/null +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -0,0 +1,35 @@ +user(); + if (! $user) { + return Response::error('Unauthenticated.'); + } + + $token = $user->currentAccessToken(); + if (! $token) { + return Response::error('Invalid token.'); + } + + if ($token->can('root') || $token->can($ability)) { + return null; + } + + return Response::error("Missing required permissions: {$ability}"); + } + + protected function resolveTeamId(Request $request): ?int + { + $token = $request->user()?->currentAccessToken(); + + return $token?->team_id; + } +} diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php new file mode 100644 index 000000000..aff7e3f76 --- /dev/null +++ b/app/Mcp/Servers/CoolifyServer.php @@ -0,0 +1,50 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return Response::error("Application [{$uuid}] not found."); + } + + // Drop relations that the server_status accessor lazy-loads — they + // pull in sensitive nested data (server.settings.sentinel_token, etc.) + $application->setRelations([]); + $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']); + + return $this->respond( + $this->scrubSensitive($application->toArray()), + $this->actionsForApplication($uuid, $application->status), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Application UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php new file mode 100644 index 000000000..4eee9c961 --- /dev/null +++ b/app/Mcp/Tools/GetDatabase.php @@ -0,0 +1,58 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId); + if (! $database) { + return Response::error("Database [{$uuid}] not found."); + } + + // Drop relations so deep server/destination data doesn't leak. + $database->setRelations([]); + $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']); + + return $this->respond( + $this->scrubSensitive($database->toArray()), + $this->actionsForDatabase($uuid, $database->status ?? null), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Database UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php new file mode 100644 index 000000000..06e91ff57 --- /dev/null +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -0,0 +1,93 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $servers = Server::whereTeamId($teamId) + ->select('id', 'name', 'uuid', 'ip', 'description') + ->with('settings:id,server_id,is_reachable,is_usable') + ->get() + ->map(fn ($s) => [ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'is_reachable' => $s->settings?->is_reachable, + 'is_usable' => $s->settings?->is_usable, + ]) + ->values() + ->all(); + + $projects = Project::where('team_id', $teamId)->get(); + + $appCount = 0; + $serviceCount = 0; + $databaseCount = 0; + $projectSummaries = []; + + foreach ($projects as $project) { + $apps = $project->applications()->count(); + $services = $project->services()->count(); + $databases = $project->databases()->count(); + + $appCount += $apps; + $serviceCount += $services; + $databaseCount += $databases; + + $projectSummaries[] = [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'counts' => [ + 'applications' => $apps, + 'services' => $services, + 'databases' => $databases, + ], + ]; + } + + return $this->respond([ + 'coolify_version' => config('constants.coolify.version'), + 'servers' => $servers, + 'projects' => $projectSummaries, + 'counts' => [ + 'servers' => count($servers), + 'projects' => count($projectSummaries), + 'applications' => $appCount, + 'services' => $serviceCount, + 'databases' => $databaseCount, + ], + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php new file mode 100644 index 000000000..fc3e72f14 --- /dev/null +++ b/app/Mcp/Tools/GetServer.php @@ -0,0 +1,57 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); + if (! $server) { + return Response::error("Server [{$uuid}] not found."); + } + + $data = $this->scrubSensitive($server->toArray()); + $data['is_reachable'] = $server->settings?->is_reachable; + $data['is_usable'] = $server->settings?->is_usable; + $data['connection_timeout'] = $server->settings?->connection_timeout; + + return $this->respond($data, $this->actionsForServer($uuid)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Server UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php new file mode 100644 index 000000000..475958272 --- /dev/null +++ b/app/Mcp/Tools/GetService.php @@ -0,0 +1,61 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid argument is required.'); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId) + ->where('uuid', $uuid) + ->first(); + + if (! $service) { + return Response::error("Service [{$uuid}] not found."); + } + + $service->setRelations([]); + $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']); + + return $this->respond( + $this->scrubSensitive($service->toArray()), + $this->actionsForService($uuid, $service->status ?? null), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Service UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php new file mode 100644 index 000000000..947d49ed1 --- /dev/null +++ b/app/Mcp/Tools/ListApplications.php @@ -0,0 +1,74 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $tagName = $request->get('tag'); + $args = $this->paginationArgs($request); + + $query = Application::ownedByCurrentTeamAPI($teamId) + ->when($tagName, function ($query, $tagName) { + $query->whereHas('tags', fn ($q) => $q->where('name', $tagName)); + }); + + $total = (clone $query)->count(); + + $summaries = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($app) => [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + 'git_repository' => $app->git_repository, + ]) + ->values() + ->all(); + + $extra = $tagName ? ['tag' => $tagName] : []; + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_applications', $args, $total, $extra), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'tag' => $schema->string()->description('Optional tag name filter.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php new file mode 100644 index 000000000..7eb1fde00 --- /dev/null +++ b/app/Mcp/Tools/ListDatabases.php @@ -0,0 +1,69 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $projects = Project::where('team_id', $teamId)->get(); + $databases = collect(); + foreach ($projects as $project) { + $databases = $databases->merge($project->databases()); + } + + $total = $databases->count(); + + $summaries = $databases + ->sortBy('name') + ->slice($args['offset'], $args['per_page']) + ->map(fn ($db) => [ + 'uuid' => $db->uuid, + 'name' => $db->name, + 'status' => $db->status ?? null, + 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db), + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_databases', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php new file mode 100644 index 000000000..9ce1576b9 --- /dev/null +++ b/app/Mcp/Tools/ListProjects.php @@ -0,0 +1,66 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $query = Project::whereTeamId($teamId); + $total = (clone $query)->count(); + + $summaries = $query + ->select('name', 'description', 'uuid') + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'name' => $p->name, + 'description' => $p->description, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_projects', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php new file mode 100644 index 000000000..20250c454 --- /dev/null +++ b/app/Mcp/Tools/ListServers.php @@ -0,0 +1,67 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable'); + $total = (clone $query)->count(); + + $summaries = $query + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($s) => [ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'is_reachable' => $s->settings?->is_reachable, + 'is_usable' => $s->settings?->is_usable, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_servers', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php new file mode 100644 index 000000000..4b33231ba --- /dev/null +++ b/app/Mcp/Tools/ListServices.php @@ -0,0 +1,68 @@ +ensureAbility($request, 'read')) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $projects = Project::where('team_id', $teamId)->get(); + $services = collect(); + foreach ($projects as $project) { + $services = $services->merge($project->services()->get()); + } + + $total = $services->count(); + + $summaries = $services + ->sortBy('name') + ->slice($args['offset'], $args['per_page']) + ->map(fn ($svc) => [ + 'uuid' => $svc->uuid, + 'name' => $svc->name, + 'status' => $svc->status ?? null, + ]) + ->values() + ->all(); + + return $this->respond( + $summaries, + [], + $this->paginationMeta('list_services', $args, $total), + ); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 6061bc863..d5c3bfa28 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -45,6 +45,7 @@ class InstanceSettings extends Model 'is_sponsorship_popup_enabled', 'dev_helper_version', 'is_wire_navigate_enabled', + 'is_mcp_server_enabled', ]; protected $casts = [ @@ -67,6 +68,7 @@ class InstanceSettings extends Model 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', 'is_wire_navigate_enabled' => 'boolean', + 'is_mcp_server_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/composer.json b/composer.json index e2b16b31b..9415aa624 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/fortify": "^1.34.0", "laravel/framework": "^12.49.0", "laravel/horizon": "^5.43.0", + "laravel/mcp": "^0.6.7", "laravel/nightwatch": "^1.24", "laravel/pail": "^1.2.4", "laravel/prompts": "^0.3.11|^0.3.11|^0.3.11", diff --git a/composer.lock b/composer.lock index 2f27235f5..36715d2c5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "40bddea995c1744e4aec517263109a2f", + "content-hash": "64b77285a7140ce68e83db2659e9a21d", "packages": [ { "name": "aws/aws-crt-php", @@ -2066,6 +2066,79 @@ }, "time": "2026-03-18T14:14:59+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-15T08:30:42+00:00" + }, { "name": "laravel/nightwatch", "version": "v1.24.4", @@ -13738,79 +13811,6 @@ }, "time": "2026-03-21T11:50:49+00:00" }, - { - "name": "laravel/mcp", - "version": "v0.6.4", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", - "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-03-19T12:37:13+00:00" - }, { "name": "laravel/pint", "version": "v1.29.0", @@ -17311,5 +17311,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..f24548142 --- /dev/null +++ b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_mcp_server_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_mcp_server_enabled'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 059b3d911..c35c0cdd6 100644 --- a/openapi.json +++ b/openapi.json @@ -8650,6 +8650,110 @@ ] } }, + "\/mcp\/enable": { + "get": { + "summary": "Enable MCP Server", + "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "enable-mcp", + "responses": { + "200": { + "description": "MCP server enabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server enabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to enable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to enable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/mcp\/disable": { + "get": { + "summary": "Disable MCP Server", + "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "disable-mcp", + "responses": { + "200": { + "description": "MCP server disabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server disabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to disable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to disable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/health": { "get": { "summary": "Healthcheck", diff --git a/openapi.yaml b/openapi.yaml index 83aa30744..eab531674 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5484,6 +5484,64 @@ paths: security: - bearerAuth: [] + /mcp/enable: + get: + summary: 'Enable MCP Server' + description: 'Enable the MCP server endpoint at /mcp (only with root permissions).' + operationId: enable-mcp + responses: + '200': + description: 'MCP server enabled.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'MCP server enabled.' } + type: object + '403': + description: 'You are not allowed to enable the MCP server.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'You are not allowed to enable the MCP server.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + /mcp/disable: + get: + summary: 'Disable MCP Server' + description: 'Disable the MCP server endpoint at /mcp (only with root permissions).' + operationId: disable-mcp + responses: + '200': + description: 'MCP server disabled.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'MCP server disabled.' } + type: object + '403': + description: 'You are not allowed to disable the MCP server.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'You are not allowed to disable the MCP server.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /health: get: summary: Healthcheck diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 6c26b453d..544ed7d4c 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -70,6 +70,17 @@ class="flex flex-col h-full gap-8 sm:flex-row"> environments! @endif +

MCP Server

+
+ +
+ @if ($is_mcp_server_enabled) + + Endpoint: {{ url('/mcp') }}
+ Authenticate with Authorization: Bearer <token> using a token created in Security » API Tokens. +
+ @endif

UI Settings

middleware(['mcp.enabled', 'auth:sanctum']); diff --git a/routes/api.php b/routes/api.php index c944a6ebc..f38576d4d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,8 @@ ], function () { Route::get('/enable', [OtherController::class, 'enable_api']); Route::get('/disable', [OtherController::class, 'disable_api']); + Route::get('/mcp/enable', [OtherController::class, 'enable_mcp']); + Route::get('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php new file mode 100644 index 000000000..34ae493cc --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -0,0 +1,194 @@ +where('id', 0)->delete(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function mcpPost(array $payload, ?string $token = null) +{ + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + ]; + if ($token) { + $headers['Authorization'] = 'Bearer '.$token; + } + + return test()->withHeaders($headers)->postJson('/mcp', $payload); +} + +function mcpListTools(string $token) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => (object) [], + ], $token); +} + +function mcpCallTool(string $token, string $name, array $arguments = []) +{ + return mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ], $token); +} + +function mcpToolJson($response): array +{ + return json_decode($response->json('result.content.0.text'), true); +} + +test('MCP endpoint returns 404 when the instance setting is disabled', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]); + Once::flush(); + + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(404); +}); + +test('MCP endpoint rejects unauthenticated requests', function () { + $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + $response->assertStatus(401); +}); + +test('MCP endpoint lists tools for an authenticated token', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpListTools($token); + $response->assertOk(); + + $toolNames = collect($response->json('result.tools'))->pluck('name')->all(); + expect($toolNames)->toContain( + 'get_infrastructure_overview', + 'list_servers', + 'get_server', + 'list_projects', + 'list_applications', + 'get_application', + 'list_databases', + 'get_database', + 'list_services', + 'get_service', + ); + expect($toolNames)->not->toContain('get_resource_status'); +}); + +test('list_projects returns summary + pagination scoped to the token team', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + + $otherTeam = Team::factory()->create(); + Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + $body = mcpToolJson($response); + + expect($body)->toHaveKey('data'); + expect($body)->toHaveKey('_pagination'); + expect($body['_pagination']['total'])->toBe(1); + expect($body['_pagination']['per_page'])->toBe(50); + expect($body['_pagination'])->not->toHaveKey('next'); + + $uuids = collect($body['data'])->pluck('uuid')->all(); + $names = collect($body['data'])->pluck('name')->all(); + expect($uuids)->toContain($project->uuid); + expect($names)->not->toContain('Theirs'); + expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']); +}); + +test('list_projects paginates with per_page cap at 100', function () { + for ($i = 0; $i < 3; $i++) { + Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]); + } + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]); + $body = mcpToolJson($response); + + expect($body['_pagination']['total'])->toBe(3); + expect($body['_pagination']['total_pages'])->toBe(2); + expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]); + expect($body['data'])->toHaveCount(2); + + // Verify max cap + $capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]); + $cappedBody = mcpToolJson($capped); + expect($cappedBody['_pagination']['per_page'])->toBe(100); +}); + +test('get_infrastructure_overview returns counts', function () { + Project::create(['name' => 'One', 'team_id' => $this->team->id]); + Project::create(['name' => 'Two', 'team_id' => $this->team->id]); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_infrastructure_overview'); + $response->assertOk(); + + $body = mcpToolJson($response); + expect($body)->toHaveKey('data'); + expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); + expect($body['data']['counts']['projects'])->toBe(2); + expect($body['data']['projects'])->toHaveCount(2); + expect($body['data']['projects'][0])->toHaveKey('counts'); +}); + +test('get_server scrubs sensitive nested data and exposes connection_timeout', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + // creating hook auto-generates a sentinel_token; bump connection_timeout + // via saveQuietly to avoid triggering restartSentinel. + $server->settings->forceFill(['connection_timeout' => 42])->saveQuietly(); + + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]); + $response->assertOk(); + + $body = mcpToolJson($response); + $raw = json_encode($body); + + expect($raw)->not->toContain('sentinel_token'); + expect($raw)->not->toContain('"team_id"'); + expect($raw)->not->toContain('"private_key_id"'); + expect($body['data']['connection_timeout'])->toBe(42); + expect($body['data']['uuid'])->toBe($server->uuid); +}); + +test('tool calls fail when the token lacks the read ability', function () { + $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken; + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text'))->toContain('Missing required permissions'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php new file mode 100644 index 000000000..2700f9b7b --- /dev/null +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -0,0 +1,107 @@ +delete(); + $settings = new InstanceSettings([ + 'is_mcp_server_enabled' => false, + 'is_api_enabled' => true, + ]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +function makeRootMcpToken(User $user): string +{ + $token = $user->createToken('mcp-root', ['root']); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => '0']); + + return $token->plainTextToken; +} + +function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string +{ + $token = $user->createToken('mcp-write', $abilities); + DB::table('personal_access_tokens') + ->where('id', $token->accessToken->id) + ->update(['team_id' => (string) $team->id]); + + return $token->plainTextToken; +} + +test('GET /api/v1/mcp/enable enables MCP server with root token', function () { + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server enabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('GET /api/v1/mcp/disable disables MCP server with root token', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeRootMcpToken($this->user); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/disable'); + + $response->assertOk(); + $response->assertJson(['message' => 'MCP server disabled.']); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot enable MCP server', function () { + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + +test('non-root token cannot disable MCP server', function () { + InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); + $token = makeNonRootMcpToken($this->user, $this->team); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/disable'); + + $response->assertStatus(403); + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); +}); + +test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { + $response = test()->getJson('/api/v1/mcp/enable'); + $response->assertStatus(401); +}); + +test('read-only token cannot toggle MCP server (lacks write ability)', function () { + $token = makeNonRootMcpToken($this->user, $this->team, ['read']); + + $response = test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/mcp/enable'); + + $response->assertStatus(403); +}); From ec6407a71f72dfa27fa29831df917aa15f4f67dc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:43:19 +0200 Subject: [PATCH 034/100] docs(design): migrate design system from .ai/ to DESIGN.md Condense verbose 1666-line AI reference into 752-line structured YAML/Markdown spec. Move from .ai/design-system.md to repo-root DESIGN.md for broader visibility. --- .ai/design-system.md | 1666 ------------------------------------------ DESIGN.md | 752 +++++++++++++++++++ 2 files changed, 752 insertions(+), 1666 deletions(-) delete mode 100644 .ai/design-system.md create mode 100644 DESIGN.md diff --git a/.ai/design-system.md b/.ai/design-system.md deleted file mode 100644 index d22adf3c6..000000000 --- a/.ai/design-system.md +++ /dev/null @@ -1,1666 +0,0 @@ -# Coolify Design System - -> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. - ---- - -## 1. Design Tokens - -### 1.1 Colors - -#### Brand / Accent - -| Token | Hex | Usage | -|---|---|---| -| `coollabs` | `#6b16ed` | Primary accent (light mode) | -| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | -| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | -| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | -| `coollabs-300` | `#4a0fa3` | Deepest brand shade | -| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | - -#### Warning Scale (used for dark-mode accent + callouts) - -| Token | Hex | -|---|---| -| `warning-50` | `#fefce8` | -| `warning-100` | `#fef9c3` | -| `warning-200` | `#fef08a` | -| `warning-300` | `#fde047` | -| `warning-400` | `#fcd452` | -| `warning-500` | `#facc15` | -| `warning-600` | `#ca8a04` | -| `warning-700` | `#a16207` | -| `warning-800` | `#854d0e` | -| `warning-900` | `#713f12` | - -#### Neutral Grays (dark mode backgrounds) - -| Token | Hex | Usage | -|---|---|---| -| `base` | `#101010` | Page background (dark) | -| `coolgray-100` | `#181818` | Component background (dark) | -| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | -| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | -| `coolgray-400` | `#282828` | Tooltip background (dark) | -| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | - -#### Semantic - -| Token | Hex | Usage | -|---|---|---| -| `success` | `#22C55E` | Running status, success alerts | -| `error` | `#dc2626` | Stopped status, danger actions, error alerts | - -#### Light Mode Defaults - -| Element | Color | -|---|---| -| Page background | `gray-50` (`#f9fafb`) | -| Component background | `white` (`#ffffff`) | -| Borders | `neutral-200` (`#e5e5e5`) | -| Primary text | `black` (`#000000`) | -| Muted text | `neutral-500` (`#737373`) | -| Placeholder text | `neutral-300` (`#d4d4d4`) | - -### 1.2 Typography - -**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) - -#### Heading Hierarchy - -> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. - -| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | -|---|---|---|---| -| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | -| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | - -#### Body Text - -| Context | Tailwind | Plain CSS | -|---|---|---| -| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | -| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | -| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | -| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | - -### 1.3 Spacing Patterns - -| Context | Value | CSS | -|---|---|---| -| Component internal padding | `p-2` | `padding: 0.5rem;` | -| Callout padding | `p-4` | `padding: 1rem;` | -| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | -| Button height | `h-8` | `height: 2rem;` | -| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | -| Button gap | `gap-2` | `gap: 0.5rem;` | -| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | -| Menu item gap | `gap-3` | `gap: 0.75rem;` | -| Section margin | `mb-12` | `margin-bottom: 3rem;` | -| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | - -### 1.4 Border Radius - -| Context | Tailwind | Plain CSS | -|---|---|---| -| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | -| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | -| Badges | `rounded-full` | `border-radius: 9999px;` | -| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | - -### 1.5 Shadows - -#### Input / Select Box-Shadow System - -Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. - -```css -/* Default state */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; - -/* Default state (dark) */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; - -/* Focus state (light) — purple left bar */ -box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; - -/* Focus state (dark) — yellow left bar */ -box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; - -/* Dirty (modified) state — same as focus */ -box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ -box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ - -/* Disabled / Readonly */ -box-shadow: none; -``` - -#### Input-Sticky Variant (thinner border) - -```css -/* Uses 1px border instead of 2px */ -box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; -``` - -### 1.6 Focus Ring System - -All interactive elements (buttons, links, checkboxes) share this focus pattern: - -**Tailwind:** -``` -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ -} - -/* dark mode */ -.dark :focus-visible { - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} -``` - -> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. - ---- - -## 2. Dark Mode Strategy - -- **Toggle method**: Class-based — `.dark` class on `` element -- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` -- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` - -### Accent Color Swap - -| Context | Light | Dark | -|---|---|---| -| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | -| Focus ring | `ring-coollabs` | `ring-warning` | -| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | -| Active nav text | `text-black` | `text-warning` | -| Helper/highlight text | `text-coollabs` | `text-warning` | -| Loading spinner | `text-coollabs` | `text-warning` | -| Scrollbar thumb | `coollabs-100` | `coollabs-100` | - -### Background Hierarchy (dark) - -``` -#101010 (base) — page background - └─ #181818 (coolgray-100) — cards, inputs, components - └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active - └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders - └─ #282828 (coolgray-400) — tooltips, hover states - └─ #323232 (coolgray-500) — subtle overlays -``` - -### Background Hierarchy (light) - -``` -#f9fafb (gray-50) — page background - └─ #ffffff (white) — cards, inputs, components - └─ #e5e5e5 (neutral-200) — borders - └─ #f5f5f5 (neutral-100) — hover backgrounds - └─ #d4d4d4 (neutral-300) — deeper hover, nav active -``` - ---- - -## 3. Component Catalog - -### 3.1 Button - -#### Default - -**Tailwind:** -``` -flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm -border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 -dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 -dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit -dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent -disabled:bg-transparent disabled:text-neutral-300 -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs -dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -.button { - display: flex; - gap: 0.5rem; - justify-content: center; - align-items: center; - padding: 0 0.5rem; - height: 2rem; - font-size: 0.875rem; - font-weight: 500; - text-transform: none; - color: #000; - background: #fff; - border: 2px solid #e5e5e5; - border-radius: 0.125rem; - outline: 0; - cursor: pointer; - min-width: fit-content; -} -.button:hover { background: #f5f5f5; } - -/* Dark */ -.dark .button { - background: #181818; - color: #fff; - border-color: #242424; -} -.dark .button:hover { - background: #202020; - color: #fff; -} - -/* Disabled */ -.button:disabled { - cursor: not-allowed; - border-color: transparent; - background: transparent; - color: #d4d4d4; -} -.dark .button:disabled { color: #525252; } -``` - -#### Highlighted (Primary Action) - -**Tailwind** (via `isHighlighted` attribute): -``` -text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 -border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white -dark:hover:bg-coollabs-100 dark:hover:text-white -``` - -**Plain CSS:** -```css -.button-highlighted { - color: #5a12c7; - background: #f5f0ff; - border-color: #6b16ed; -} -.button-highlighted:hover { - background: #6b16ed; - color: #fff; -} -.dark .button-highlighted { - color: #fff; - background: rgba(107, 22, 237, 0.2); - border-color: #7317ff; -} -.dark .button-highlighted:hover { - background: #7317ff; - color: #fff; -} -``` - -#### Error / Danger - -**Tailwind** (via `isError` attribute): -``` -text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 -border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white -dark:hover:bg-red-800 dark:hover:text-white -``` - -**Plain CSS:** -```css -.button-error { - color: #991b1b; - background: #fef2f2; - border-color: #fca5a5; -} -.button-error:hover { - background: #fca5a5; - color: #fff; -} -.dark .button-error { - color: #fca5a5; - background: rgba(127, 29, 29, 0.3); - border-color: #991b1b; -} -.dark .button-error:hover { - background: #991b1b; - color: #fff; -} -``` - -#### Loading Indicator - -Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). - ---- - -### 3.2 Input - -**Tailwind:** -``` -block py-1.5 w-full text-sm text-black rounded-sm border-0 -dark:bg-coolgray-100 dark:text-white -disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 -dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 -placeholder:text-neutral-300 dark:placeholder:text-neutral-700 -read-only:text-neutral-500 read-only:bg-neutral-200 -focus-visible:outline-none -``` - -**Plain CSS:** -```css -.input { - display: block; - padding: 0.375rem 0.5rem; - width: 100%; - font-size: 0.875rem; - color: #000; - background: #fff; - border: 0; - border-radius: 0.125rem; - box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; -} -.input:focus-visible { - outline: none; - box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; -} -.input::placeholder { color: #d4d4d4; } -.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } -.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } -.input[type="password"] { padding-right: 2.4rem; } - -/* Dark */ -.dark .input { - background: #181818; - color: #fff; - box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; -} -.dark .input:focus-visible { - box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; -} -.dark .input::placeholder { color: #404040; } -.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } -.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } -``` - -#### Dirty (Modified) State - -When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. - ---- - -### 3.3 Select - -Same base styles as Input, plus a custom dropdown arrow SVG: - -**Tailwind:** -``` -w-full block py-1.5 text-sm text-black rounded-sm border-0 -dark:bg-coolgray-100 dark:text-white -disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 -focus-visible:outline-none -``` - -**Additional plain CSS for the dropdown arrow:** -```css -.select { - /* ...same as .input base... */ - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; - background-repeat: no-repeat; - background-size: 1rem 1rem; - padding-right: 2.5rem; - appearance: none; -} - -/* Dark mode: white stroke arrow */ -.dark .select { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); -} -``` - ---- - -### 3.4 Checkbox - -**Tailwind:** -``` -dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer -dark:disabled:bg-base dark:disabled:cursor-not-allowed -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs -dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Container:** -``` -flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit -dark:hover:bg-coolgray-100 cursor-pointer -``` - -**Plain CSS:** -```css -.checkbox { - border-color: #404040; - color: #282828; - background: #181818; - border-radius: 0.125rem; - cursor: pointer; -} -.checkbox:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} - -.checkbox-container { - display: flex; - flex-direction: row; - align-items: center; - gap: 1rem; - padding: 0.25rem 0.5rem 0.25rem 0; - min-width: fit-content; - cursor: pointer; -} -.dark .checkbox-container:hover { background: #181818; } -``` - ---- - -### 3.5 Textarea - -Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). - -**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: - -**Tailwind:** -``` -block w-full text-sm text-black rounded-sm border border-neutral-200 -dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 -font-mono focus-visible:outline-none focus-visible:ring-2 -focus-visible:ring-coollabs dark:focus-visible:ring-warning -focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base -``` - -**Plain CSS:** -```css -.textarea { - display: block; - width: 100%; - font-size: 0.875rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - color: #000; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; -} -.textarea:focus-visible { - outline: none; - box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; -} -.dark .textarea { - background: #181818; - color: #fff; - border-color: #242424; -} -.dark .textarea:focus-visible { - box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; -} -``` - -> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. - ---- - -### 3.6 Box / Card - -#### Standard Box - -**Tailwind:** -``` -relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] -dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black -border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 -dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm -``` - -**Plain CSS:** -```css -.box { - position: relative; - display: flex; - flex-direction: column; - padding: 0.5rem; - min-height: 4rem; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - color: #000; - cursor: pointer; - transition: background-color 150ms, color 150ms; - text-decoration: none; -} -.box:hover { background: #f5f5f5; color: #000; } - -.dark .box { - background: #181818; - border-color: #242424; - color: #fff; -} -.dark .box:hover { - background: #7317ff; - color: #fff; -} - -/* IMPORTANT: child text must also turn white/black on hover, - since description text (#737373) is invisible on purple bg */ -.box:hover .box-title { color: #000; } -.box:hover .box-description { color: #000; } -.dark .box:hover .box-title { color: #fff; } -.dark .box:hover .box-description { color: #fff; } - -/* Desktop: row layout */ -@media (min-width: 1024px) { - .box { flex-direction: row; } -} -``` - -#### Coolbox (Ring Hover) - -**Tailwind:** -``` -relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded -border border-neutral-200 dark:border-coolgray-400 hover:ring-2 -dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] -``` - -**Plain CSS:** -```css -.coolbox { - position: relative; - display: flex; - padding: 0.5rem; - min-height: 4rem; - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 0.25rem; - cursor: pointer; - transition: all 150ms; -} -.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } - -.dark .coolbox { - background: #181818; - border-color: #282828; -} -.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } -``` - -#### Box Text - -> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. - -```css -.box-title { - font-weight: 700; - color: #000; /* light mode: black */ -} -.dark .box-title { - color: #fff; /* dark mode: MUST be white, not grey */ -} - -.box-description { - font-size: 0.75rem; - font-weight: 700; - color: #737373; -} -/* On hover: description must become visible against colored bg */ -.box:hover .box-description { color: #000; } -.dark .box:hover .box-description { color: #fff; } -``` - ---- - -### 3.7 Badge / Status Indicator - -**Tailwind:** -``` -inline-block w-3 h-3 text-xs font-bold rounded-full leading-none -border border-neutral-200 dark:border-black -``` - -**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) - -**Plain CSS:** -```css -.badge { - display: inline-block; - width: 0.75rem; - height: 0.75rem; - border-radius: 9999px; - border: 1px solid #e5e5e5; -} -.dark .badge { border-color: #000; } - -.badge-success { background: #22C55E; } -.badge-warning { background: #fcd452; } -.badge-error { background: #dc2626; } -``` - -#### Status Text Pattern - -Status indicators combine a badge dot with text: - -```html -
-
-
- Running -
-
-``` - -| Status | Badge Class | Text Color | -|---|---|---| -| Running | `badge-success` | `text-success` (`#22C55E`) | -| Stopped | `badge-error` | `text-error` (`#dc2626`) | -| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | -| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | - ---- - -### 3.8 Dropdown - -**Container Tailwind:** -``` -p-1 mt-1 bg-white border rounded-sm shadow-sm -dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 -``` - -**Item Tailwind:** -``` -flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs -transition-colors cursor-pointer select-none dark:text-white -hover:bg-neutral-100 dark:hover:bg-coollabs -outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs -``` - -**Plain CSS:** -```css -.dropdown { - padding: 0.25rem; - margin-top: 0.25rem; - background: #fff; - border: 1px solid #d4d4d4; - border-radius: 0.125rem; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); -} -.dark .dropdown { - background: #202020; - border-color: #242424; -} - -.dropdown-item { - display: flex; - position: relative; - gap: 0.5rem; - justify-content: flex-start; - align-items: center; - padding: 0.25rem 1rem 0.25rem 0.5rem; - width: 100%; - font-size: 0.75rem; - cursor: pointer; - user-select: none; - transition: background-color 150ms; -} -.dropdown-item:hover { background: #f5f5f5; } -.dark .dropdown-item { color: #fff; } -.dark .dropdown-item:hover { background: #6b16ed; } -``` - ---- - -### 3.9 Sidebar / Navigation - -#### Sidebar Container + Page Layout - -The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. - -**Tailwind (sidebar wrapper — desktop):** -``` -hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 -``` - -**Tailwind (sidebar inner — scrollable):** -``` -flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 -``` - -**Tailwind (nav element):** -``` -flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base -``` - -**Tailwind (main content area):** -``` -lg:pl-56 -``` - -**Tailwind (main content padding):** -``` -p-4 sm:px-6 lg:px-8 lg:py-6 -``` - -**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** -``` -sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden -bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 -``` - -**Tailwind (mobile hamburger icon):** -``` --m-2.5 p-2.5 dark:text-warning -``` - -**Plain CSS:** -```css -/* Sidebar — desktop only */ -.sidebar { - display: none; -} -@media (min-width: 1024px) { - .sidebar { - display: flex; - flex-direction: column; - position: fixed; - top: 0; - bottom: 0; - left: 0; - z-index: 50; - width: 14rem; /* 224px */ - min-width: 0; - } -} - -.sidebar-inner { - display: flex; - flex-direction: column; - flex-grow: 1; - overflow-y: auto; - gap: 1.25rem; - min-width: 0; -} - -/* Nav element */ -.sidebar-nav { - display: flex; - flex-direction: column; - flex: 1; - padding: 0 0.5rem; - background: #fff; - border-right: 1px solid #d4d4d4; -} -.dark .sidebar-nav { - background: #101010; - border-right-color: #202020; -} - -/* Main content offset */ -@media (min-width: 1024px) { - .main-content { padding-left: 14rem; } -} - -.main-content-inner { - padding: 1rem; -} -@media (min-width: 640px) { - .main-content-inner { padding: 1rem 1.5rem; } -} -@media (min-width: 1024px) { - .main-content-inner { padding: 1.5rem 2rem; } -} - -/* Mobile top bar — visible below lg breakpoint */ -.mobile-topbar { - position: sticky; - top: 0; - z-index: 40; - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem; - gap: 1.5rem; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(212, 212, 212, 0.5); -} -.dark .mobile-topbar { - background: rgba(16, 16, 16, 0.95); - border-bottom-color: rgba(32, 32, 32, 0.5); -} -@media (min-width: 1024px) { - .mobile-topbar { display: none; } -} - -/* Mobile sidebar overlay (shown when hamburger is tapped) */ -.sidebar-mobile { - position: relative; - display: flex; - flex: 1; - width: 100%; - max-width: 14rem; - min-width: 0; -} -.sidebar-mobile-scroll { - display: flex; - flex-direction: column; - padding-bottom: 0.5rem; - overflow-y: auto; - min-width: 14rem; - gap: 1.25rem; - min-width: 0; -} -.dark .sidebar-mobile-scroll { background: #181818; } -``` - -#### Sidebar Header (Logo + Search) - -**Tailwind:** -``` -flex lg:pt-6 pt-4 pb-4 pl-2 -``` - -**Logo:** -``` -text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity -``` - -**Search button:** -``` -flex items-center gap-1.5 px-2.5 py-1.5 -bg-neutral-100 dark:bg-coolgray-100 -border border-neutral-300 dark:border-coolgray-200 -rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors -``` - -**Search kbd hint:** -``` -px-1 py-0.5 text-xs font-semibold -text-neutral-500 dark:text-neutral-400 -bg-neutral-200 dark:bg-coolgray-200 rounded -``` - -**Plain CSS:** -```css -.sidebar-header { - display: flex; - padding: 1rem 0 1rem 0.5rem; -} -@media (min-width: 1024px) { - .sidebar-header { padding-top: 1.5rem; } -} - -.sidebar-logo { - font-size: 1.5rem; - font-weight: 700; - letter-spacing: 0.025em; - color: #000; - text-decoration: none; -} -.dark .sidebar-logo { color: #fff; } -.sidebar-logo:hover { opacity: 0.8; } - -.sidebar-search-btn { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.625rem; - background: #f5f5f5; - border: 1px solid #d4d4d4; - border-radius: 0.375rem; - cursor: pointer; - transition: background-color 150ms; -} -.sidebar-search-btn:hover { background: #e5e5e5; } -.dark .sidebar-search-btn { - background: #181818; - border-color: #202020; -} -.dark .sidebar-search-btn:hover { background: #202020; } - -.sidebar-search-kbd { - padding: 0.125rem 0.25rem; - font-size: 0.75rem; - font-weight: 600; - color: #737373; - background: #e5e5e5; - border-radius: 0.25rem; -} -.dark .sidebar-search-kbd { - color: #a3a3a3; - background: #202020; -} -``` - -#### Menu Item List - -**Tailwind (list container):** -``` -flex flex-col flex-1 gap-y-7 -``` - -**Tailwind (inner list):** -``` -flex flex-col h-full space-y-1.5 -``` - -**Plain CSS:** -```css -.menu-list { - display: flex; - flex-direction: column; - flex: 1; - gap: 1.75rem; - list-style: none; - padding: 0; - margin: 0; -} - -.menu-list-inner { - display: flex; - flex-direction: column; - height: 100%; - gap: 0.375rem; - list-style: none; - padding: 0; - margin: 0; -} -``` - -#### Menu Item - -**Tailwind:** -``` -flex gap-3 items-center px-2 py-1 w-full text-sm -dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 -``` - -#### Menu Item Active - -**Tailwind:** -``` -text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden -``` - -#### Menu Item Icon / Label - -``` -/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white -/* Label */ min-w-0 flex-1 truncate -``` - -**Plain CSS:** -```css -.menu-item { - display: flex; - gap: 0.75rem; - align-items: center; - padding: 0.25rem 0.5rem; - width: 100%; - font-size: 0.875rem; - border-radius: 0.125rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.menu-item:hover { background: #d4d4d4; } -.dark .menu-item:hover { background: #181818; color: #fff; } - -.menu-item-active { - color: #000; - background: #e5e5e5; - border-radius: 0.125rem; -} -.dark .menu-item-active { - background: #202020; - color: #fcd452; -} - -.menu-item-icon { - flex-shrink: 0; - width: 1.5rem; - height: 1.5rem; -} - -.menu-item-label { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -``` - -#### Sub-Menu Item - -```css -.sub-menu-item { - /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.25rem 0.5rem; - width: 100%; - font-size: 0.875rem; - border-radius: 0.125rem; -} -.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } -``` - ---- - -### 3.10 Callout / Alert - -Four types: `warning`, `danger`, `info`, `success`. - -**Structure:** -```html -
-
-
-
Title
-
Content
-
-
-``` - -**Base Tailwind:** -``` -relative p-4 border rounded-lg -``` - -**Type Colors:** - -| Type | Background | Border | Title Text | Body Text | -|---|---|---|---|---| -| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | -| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | -| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | -| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | - -**Plain CSS (warning example):** -```css -.callout { - position: relative; - padding: 1rem; - border: 1px solid; - border-radius: 0.5rem; -} - -.callout-warning { - background: #fefce8; - border-color: #fde047; -} -.dark .callout-warning { - background: rgba(113, 63, 18, 0.3); - border-color: #854d0e; -} - -.callout-title { - font-size: 1rem; - font-weight: 700; -} -.callout-warning .callout-title { color: #854d0e; } -.dark .callout-warning .callout-title { color: #fde047; } - -.callout-text { - margin-top: 0.5rem; - font-size: 0.875rem; -} -.callout-warning .callout-text { color: #a16207; } -.dark .callout-warning .callout-text { color: #fef08a; } -``` - -**Icon colors per type:** -- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) -- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) -- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) -- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) - ---- - -### 3.11 Toast / Notification - -**Container Tailwind:** -``` -relative flex flex-col items-start -shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] -w-full transition-all duration-100 ease-out -dark:bg-coolgray-100 bg-white -dark:border dark:border-coolgray-200 -rounded-sm sm:max-w-xs -``` - -**Plain CSS:** -```css -.toast { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - max-width: 20rem; - background: #fff; - border-radius: 0.125rem; - box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); - transition: all 100ms ease-out; -} -.dark .toast { - background: #181818; - border: 1px solid #202020; -} -``` - -**Icon colors per toast type:** - -| Type | Color | Hex | -|---|---|---| -| Success | `text-green-500` | `#22c55e` | -| Info | `text-blue-500` | `#3b82f6` | -| Warning | `text-orange-400` | `#fb923c` | -| Danger | `text-red-500` | `#ef4444` | - -**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. - ---- - -### 3.12 Modal - -**Tailwind (dialog-based):** -``` -rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col -``` - -**Modal Input variant container:** -``` -relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl -border rounded-sm drop-shadow-sm -bg-white border-neutral-200 -dark:bg-base dark:border-coolgray-300 -flex flex-col -``` - -**Modal Confirmation container:** -``` -relative w-full border rounded-sm -min-w-full lg:min-w-[36rem] max-w-[48rem] -max-h-[calc(100vh-2rem)] -bg-neutral-100 border-neutral-400 -dark:bg-base dark:border-coolgray-300 -flex flex-col -``` - -**Plain CSS:** -```css -.modal-box { - border-radius: 0.125rem; - max-height: calc(100vh - 5rem); - display: flex; - flex-direction: column; -} - -.modal-input { - position: relative; - width: 100%; - border: 1px solid #e5e5e5; - border-radius: 0.125rem; - filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); - background: #fff; - display: flex; - flex-direction: column; -} -.dark .modal-input { - background: #101010; - border-color: #242424; -} - -/* Desktop sizing */ -@media (min-width: 1024px) { - .modal-input { - width: auto; - min-width: 42rem; - max-width: 56rem; - } -} -``` - -**Modal header:** -```css -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.5rem; - flex-shrink: 0; -} -.modal-header h3 { - font-size: 1.5rem; - font-weight: 700; -} -``` - -**Close button:** -```css -.modal-close { - width: 2rem; - height: 2rem; - border-radius: 9999px; - color: #fff; -} -.modal-close:hover { background: #242424; } -``` - ---- - -### 3.13 Slide-Over Panel - -**Tailwind:** -``` -fixed inset-y-0 right-0 flex max-w-full pl-10 -``` - -**Inner panel:** -``` -max-w-xl w-screen -flex flex-col h-full py-6 -border-l shadow-lg -bg-neutral-50 dark:bg-base -dark:border-neutral-800 border-neutral-200 -``` - -**Plain CSS:** -```css -.slide-over { - position: fixed; - top: 0; - bottom: 0; - right: 0; - display: flex; - max-width: 100%; - padding-left: 2.5rem; -} - -.slide-over-panel { - max-width: 36rem; - width: 100vw; - display: flex; - flex-direction: column; - height: 100%; - padding: 1.5rem 0; - border-left: 1px solid #e5e5e5; - box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); - background: #fafafa; -} -.dark .slide-over-panel { - background: #101010; - border-color: #262626; -} -``` - ---- - -### 3.14 Tag - -**Tailwind:** -``` -px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 -dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 -``` - -**Plain CSS:** -```css -.tag { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 700; - color: #737373; - background: #f5f5f5; - cursor: pointer; -} -.tag:hover { background: #e5e5e5; } -.dark .tag { background: #181818; } -.dark .tag:hover { background: #242424; } -``` - ---- - -### 3.15 Loading Spinner - -**Tailwind:** -``` -w-4 h-4 text-coollabs dark:text-warning animate-spin -``` - -**Plain CSS + SVG:** -```css -.loading-spinner { - width: 1rem; - height: 1rem; - color: #6b16ed; - animation: spin 1s linear infinite; -} -.dark .loading-spinner { color: #fcd452; } - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -``` - -**SVG structure:** -```html - - - - -``` - ---- - -### 3.16 Helper / Tooltip - -**Tailwind (trigger icon):** -``` -cursor-pointer text-coollabs dark:text-warning -``` - -**Tailwind (popup):** -``` -hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block -dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 -dark:text-neutral-300 max-w-sm whitespace-normal break-words -``` - -**Plain CSS:** -```css -.helper-icon { - cursor: pointer; - color: #6b16ed; -} -.dark .helper-icon { color: #fcd452; } - -.helper-popup { - display: none; - position: absolute; - z-index: 40; - font-size: 0.75rem; - border-radius: 0.125rem; - color: #404040; - background: #e5e5e5; - max-width: 24rem; - white-space: normal; - word-break: break-word; - padding: 1rem; -} -.dark .helper-popup { - background: #282828; - color: #d4d4d4; - border: 1px solid #323232; -} - -/* Show on parent hover */ -.helper:hover .helper-popup { display: block; } -``` - ---- - -### 3.17 Highlighted Text - -**Tailwind:** -``` -inline-block font-bold text-coollabs dark:text-warning -``` - -**Plain CSS:** -```css -.text-highlight { - display: inline-block; - font-weight: 700; - color: #6b16ed; -} -.dark .text-highlight { color: #fcd452; } -``` - ---- - -### 3.18 Scrollbar - -**Tailwind:** -``` -scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 -dark:scrollbar-track-coolgray-200 scrollbar-thin -``` - -**Plain CSS:** -```css -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: #e5e5e5; } -::-webkit-scrollbar-thumb { background: #7317ff; } -.dark ::-webkit-scrollbar-track { background: #202020; } -``` - ---- - -### 3.19 Table - -**Plain CSS:** -```css -table { min-width: 100%; border-collapse: separate; } -table, tbody { border-bottom: 1px solid #d4d4d4; } -.dark table, .dark tbody { border-color: #202020; } - -thead { text-transform: uppercase; } - -tr { color: #000; } -tr:hover { background: #e5e5e5; } -.dark tr { color: #a3a3a3; } -.dark tr:hover { background: #000; } - -th { - padding: 0.875rem 0.75rem; - text-align: left; - color: #000; -} -.dark th { color: #fff; } -th:first-child { padding-left: 1.5rem; } - -td { padding: 1rem 0.75rem; white-space: nowrap; } -td:first-child { padding-left: 1.5rem; font-weight: 700; } -``` - ---- - -### 3.20 Keyboard Shortcut Indicator - -**Tailwind:** -``` -px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning -``` - -**Plain CSS:** -```css -.kbd { - padding: 0 0.5rem; - font-size: 0.75rem; - border-radius: 0.125rem; - border: 1px dashed #404040; -} -.dark .kbd { color: #fcd452; } -``` - ---- - -## 4. Base Element Styles - -These global styles are applied to all HTML elements: - -```css -/* Page */ -html, body { - width: 100%; - min-height: 100%; - background: #f9fafb; - font-family: Inter, sans-serif; -} -.dark html, .dark body { - background: #101010; - color: #a3a3a3; -} - -body { - min-height: 100vh; - font-size: 0.875rem; - -webkit-font-smoothing: antialiased; - overflow-x: hidden; -} - -/* Links */ -a:hover { color: #000; } -.dark a:hover { color: #fff; } - -/* Labels */ -.dark label { color: #a3a3a3; } - -/* Sections */ -section { margin-bottom: 3rem; } - -/* Default border color override */ -*, ::after, ::before, ::backdrop { - border-color: #202020; /* coolgray-200 */ -} - -/* Select options */ -.dark option { - color: #fff; - background: #181818; -} -``` - ---- - -## 5. Interactive State Reference - -### Focus - -| Element Type | Mechanism | Light | Dark | -|---|---|---|---| -| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | -| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | -| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | - -### Hover - -| Element | Light | Dark | -|---|---|---| -| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | -| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | -| Button (error) | `bg-red-300` | `bg-red-800` | -| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | -| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | -| Menu item | `bg-neutral-300` | `bg-coolgray-100` | -| Dropdown item | `bg-neutral-100` | `bg-coollabs` | -| Table row | `bg-neutral-200` | `bg-black` | -| Link | `text-black` | `text-white` | -| Checkbox container | — | `bg-coolgray-100` | - -### Disabled - -```css -/* Universal disabled pattern */ -:disabled { - cursor: not-allowed; - color: #d4d4d4; /* neutral-300 */ - background: transparent; - border-color: transparent; -} -.dark :disabled { - color: #525252; /* neutral-600 */ -} - -/* Input-specific */ -.input:disabled { - background: #e5e5e5; /* neutral-200 */ - color: #737373; /* neutral-500 */ - box-shadow: none; -} -.dark .input:disabled { - background: rgba(24, 24, 24, 0.4); - box-shadow: none; -} -``` - -### Readonly - -```css -.input:read-only { - color: #737373; - background: #e5e5e5; - box-shadow: none; -} -.dark .input:read-only { - color: #737373; - background: rgba(24, 24, 24, 0.4); - box-shadow: none; -} -``` - ---- - -## 6. CSS Custom Properties (Theme Tokens) - -For use in any CSS framework or plain CSS: - -```css -:root { - /* Font */ - --font-sans: Inter, sans-serif; - - /* Brand */ - --color-base: #101010; - --color-coollabs: #6b16ed; - --color-coollabs-50: #f5f0ff; - --color-coollabs-100: #7317ff; - --color-coollabs-200: #5a12c7; - --color-coollabs-300: #4a0fa3; - - /* Neutral grays (dark backgrounds) */ - --color-coolgray-100: #181818; - --color-coolgray-200: #202020; - --color-coolgray-300: #242424; - --color-coolgray-400: #282828; - --color-coolgray-500: #323232; - - /* Warning / dark accent */ - --color-warning: #fcd452; - --color-warning-50: #fefce8; - --color-warning-100: #fef9c3; - --color-warning-200: #fef08a; - --color-warning-300: #fde047; - --color-warning-400: #fcd452; - --color-warning-500: #facc15; - --color-warning-600: #ca8a04; - --color-warning-700: #a16207; - --color-warning-800: #854d0e; - --color-warning-900: #713f12; - - /* Semantic */ - --color-success: #22C55E; - --color-error: #dc2626; -} -``` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..9520fdf23 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,752 @@ +--- +version: alpha +name: Coolify +description: Self-hosted PaaS. Dark-first utilitarian UI. Purple (light) / yellow (dark) accent swap. Sharp 2px radii. Inset box-shadow inputs with 4px dirty-bar indicator. +colors: + # Brand + coollabs: "#6b16ed" + coollabs-50: "#f5f0ff" + coollabs-100: "#7317ff" + coollabs-200: "#5a12c7" + coollabs-300: "#4a0fa3" + # Dark-mode accent (warning scale) + warning: "#fcd452" + warning-50: "#fefce8" + warning-100: "#fef9c3" + warning-200: "#fef08a" + warning-300: "#fde047" + warning-400: "#fcd452" + warning-500: "#facc15" + warning-600: "#ca8a04" + warning-700: "#a16207" + warning-800: "#854d0e" + warning-900: "#713f12" + # Dark surfaces + base: "#101010" + coolgray-100: "#181818" + coolgray-200: "#202020" + coolgray-300: "#242424" + coolgray-400: "#282828" + coolgray-500: "#323232" + # Light surfaces (Tailwind neutrals) + surface: "#ffffff" + background: "#f9fafb" + border: "#e5e5e5" + text: "#000000" + text-muted: "#737373" + text-placeholder: "#d4d4d4" + # Semantic + success: "#22C55E" + error: "#dc2626" + primary: "{colors.coollabs}" +typography: + h1: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.875rem + fontWeight: 700 + lineHeight: 1.2 + h2: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.25rem + fontWeight: 700 + h3: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1.125rem + fontWeight: 700 + h4: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 1rem + fontWeight: 700 + body-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 400 + lineHeight: 1.25rem + label-md: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.875rem + fontWeight: 500 + label-sm: + fontFamily: "'Geist Sans', Inter, sans-serif" + fontSize: 0.75rem + fontWeight: 700 + lineHeight: 1rem + mono: + fontFamily: "'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace" + fontSize: 0.875rem + fontWeight: 400 +rounded: + sm: 0.125rem # default — inputs, buttons, cards, modals + md: 0.25rem # coolbox + lg: 0.5rem # callouts + full: 9999px # badges, pills +spacing: + xs: 0.25rem + sm: 0.5rem + md: 1rem + lg: 1.5rem + xl: 2rem + section: 3rem + sidebar-width: 14rem + button-height: 2rem + card-min-height: 4rem + input-py: 0.375rem +components: + button: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + height: "{spacing.button-height}" + padding: 0 0.5rem + button-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + button-hover: + backgroundColor: "#f5f5f5" + button-hover-dark: + backgroundColor: "{colors.coolgray-200}" + button-highlighted: + backgroundColor: "{colors.coollabs-50}" + textColor: "{colors.coollabs-200}" + button-highlighted-hover: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + button-error: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + button-error-hover: + backgroundColor: "#fca5a5" + textColor: "#ffffff" + input: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: 0.375rem 0.5rem + input-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + textarea: + typography: "{typography.mono}" + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + box: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + box-dark: + backgroundColor: "{colors.coolgray-100}" + textColor: "#ffffff" + box-hover: + backgroundColor: "#f5f5f5" + box-hover-dark: + backgroundColor: "{colors.coollabs-100}" + textColor: "#ffffff" + coolbox: + backgroundColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "{spacing.sm}" + height: "{spacing.card-min-height}" + badge-success: + backgroundColor: "{colors.success}" + size: 0.75rem + rounded: "{rounded.full}" + badge-warning: + backgroundColor: "{colors.warning}" + size: 0.75rem + rounded: "{rounded.full}" + badge-error: + backgroundColor: "{colors.error}" + size: 0.75rem + rounded: "{rounded.full}" + deprecated-badge: + backgroundColor: "rgba(252, 212, 82, 0.15)" + textColor: "{colors.warning}" + rounded: "{rounded.full}" + padding: 0.125rem 0.5rem + callout-warning: + backgroundColor: "{colors.warning-50}" + textColor: "{colors.warning-800}" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-danger: + backgroundColor: "#fef2f2" + textColor: "#991b1b" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-info: + backgroundColor: "#eff6ff" + textColor: "#1e40af" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + callout-success: + backgroundColor: "#f0fdf4" + textColor: "#166534" + rounded: "{rounded.lg}" + padding: "{spacing.md}" + dropdown: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.xs}" + dropdown-dark: + backgroundColor: "{colors.coolgray-200}" + dropdown-item-hover: + backgroundColor: "#f5f5f5" + dropdown-item-hover-dark: + backgroundColor: "{colors.coollabs}" + textColor: "#ffffff" + menu-item-active: + backgroundColor: "#e5e5e5" + textColor: "{colors.text}" + rounded: "{rounded.sm}" + menu-item-active-dark: + backgroundColor: "{colors.coolgray-200}" + textColor: "{colors.warning}" + tag: + backgroundColor: "#f5f5f5" + textColor: "{colors.text-muted}" + padding: 0.25rem 0.5rem + kbd: + rounded: "{rounded.sm}" + padding: 0 0.5rem + toast: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + padding: "{spacing.md}" + width: 20rem + modal-input: + backgroundColor: "{colors.surface}" + rounded: "{rounded.sm}" + modal-input-dark: + backgroundColor: "{colors.base}" + modal-confirmation: + backgroundColor: "#f5f5f5" + rounded: "{rounded.sm}" + modal-confirmation-dark: + backgroundColor: "{colors.base}" +--- + +# Coolify Design System + +## Overview + +Coolify is a self-hosted PaaS (Heroku/Netlify/Vercel alternative) built with Laravel 12, Livewire 3, and Tailwind CSS v4. UI is **dark-first, dense, utilitarian** — operators want information density over whitespace. + +Brand personality: precise, engineered, no-nonsense. No flourish. No gradients outside a single branded upsell. Flat surfaces differentiated by tonal depth, not shadow. + +Two signature traits define the system: + +1. **Purple/Yellow accent swap.** Light mode uses `coollabs` purple `#6b16ed`. Dark mode swaps to `warning` yellow `#fcd452` for focus rings, active nav items, helper icons, loading spinners, highlighted text, helper links. Never use purple as an accent in dark mode. +2. **Inset box-shadow inputs with a 4px "dirty bar".** Inputs and selects have no border — they use `box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px `. When the field is focused or has unsaved changes (`wire:dirty`), the left 4px becomes the accent color — a live visual indicator of modified state. This is the single most distinctive UI detail in Coolify. + +Sharp geometry everywhere: 2px corner radius by default (`rounded-sm`). 8px only on callouts. Shadows used sparingly — one `shadow-sm` on boxes, one drop-shadow on toasts. The rest is flat tonal layers. + +## Colors + +Source of truth: `resources/css/app.css` `@theme` block (Tailwind v4). + +### Palettes + +- **Primary / Coollabs (`#6b16ed`)** — brand purple. Light-mode accent. Used for focus rings, active states, highlighted buttons, spinners, scrollbar thumb. Scale: `coollabs-50 #f5f0ff` (backgrounds), `coollabs #6b16ed` (base), `coollabs-100 #7317ff` (dark-mode button hover), `coollabs-200 #5a12c7` (light-mode text), `coollabs-300 #4a0fa3` (deepest). +- **Warning (`#fcd452`)** — dark-mode accent + callout palette. Full yellow scale `warning-50` through `warning-900`. Swaps in for coollabs under `.dark`. +- **Coolgray (dark surface ladder)** — five shades building dark-mode depth: `base #101010` (page) → `coolgray-100 #181818` (components) → `-200 #202020` (elevated / active nav) → `-300 #242424` (input borders, button borders) → `-400 #282828` (tooltips) → `-500 #323232` (subtle overlays). +- **Semantic** — `success #22C55E` for running/healthy, `error #dc2626` for stopped/danger. +- **Light surfaces** — `gray-50 #f9fafb` (page), `white` (components), `neutral-200 #e5e5e5` (borders), `neutral-500 #737373` (muted text), `neutral-300 #d4d4d4` (placeholders). + +### Dark-mode heading rule (critical) + +Body default text in dark mode is `neutral-400 #a3a3a3`. Headings and card titles MUST explicitly force `text-white` — otherwise they render near-invisible on `coolgray-100 #181818`. This is enforced globally: `h1–h4` all have `dark:text-white` in `app.css`. + +### Default border override + +Tailwind v4 defaults `border-color` to `currentcolor`. Coolify overrides it in `@layer base`: + +```css +*, ::after, ::before, ::backdrop, ::file-selector-button { + border-color: var(--color-coolgray-200, currentcolor); +} +``` + +So any `border` utility without an explicit color gets `coolgray-200 #202020` in dark mode. + +## Typography + +Fonts loaded in `resources/css/fonts.css` (all `woff2`, `font-display: swap`): + +- **Geist Sans** — primary UI font. Variable weight `100 900`. Inter as fallback (static weights 100–900). +- **Geist Mono** — monospace for code, logs, textareas. Variable weight `100 900`. + +Applied via `@theme`: + +```css +--font-sans: 'Geist Sans', Inter, sans-serif; +--font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +``` + +### Heading hierarchy (Tailwind utilities) + +| Element | Utility | +|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | +| `h2` | `text-xl font-bold dark:text-white` | +| `h3` | `text-lg font-bold dark:text-white` | +| `h4` | `text-base font-bold dark:text-white` | + +### Body + +| Context | Utility | +|---|---| +| Body default | `text-sm font-sans antialiased` | +| Label | `text-sm font-medium` | +| Badge / status text | `text-xs font-bold` | +| Box description | `text-xs font-bold text-neutral-500` | +| Caption / kbd | `text-xs` | + +## Layout + +Fixed left sidebar layout on desktop. Mobile collapses to a sticky top bar with hamburger menu overlay. + +### Structure + +- **Sidebar** — fixed, `w-56` (14rem / 224px), `hidden lg:flex`. Inner `flex flex-col overflow-y-auto gap-y-5 scrollbar`. Nav `bg-white dark:bg-base border-r`. +- **Main content** — `lg:pl-56` offset. Inner padding `p-4 sm:px-6 lg:px-8 lg:py-6`. +- **Mobile top bar** — `sticky top-0 z-40 lg:hidden` with `bg-white/95 dark:bg-base/95 backdrop-blur-sm`. + +### Spacing scale + +| Token | Value | Use | +|---|---|---| +| `p-2` | 0.5rem | Component internal padding | +| `p-4` | 1rem | Callout padding | +| `py-1.5` | 0.375rem | Input vertical padding | +| `h-8` | 2rem | Button height | +| `px-2` | 0.5rem | Button horizontal padding | +| `gap-2` | 0.5rem | Button gap | +| `px-2 py-1` | 0.25rem / 0.5rem | Menu item padding | +| `gap-3` | 0.75rem | Menu item gap | +| `mb-12` | 3rem | Section margin | +| `min-h-[4rem]` | 4rem | Card min-height | + +No grid system — flex layouts everywhere. + +## Elevation & Depth + +**Flat + tonal.** Hierarchy comes from background color, not shadows. + +### Dark tonal ladder + +``` +#101010 (base) page background + #181818 (coolgray-100) cards, inputs, components + #202020 (coolgray-200) elevated surfaces, borders, nav active + #242424 (coolgray-300) input borders, button borders + #282828 (coolgray-400) tooltips, hover states + #323232 (coolgray-500) subtle overlays +``` + +### Light tonal ladder + +``` +#f9fafb (gray-50) page background + #ffffff (white) cards, inputs, components + #e5e5e5 (neutral-200) borders + #f5f5f5 (neutral-100) hover backgrounds + #d4d4d4 (neutral-300) deeper hover, nav active +``` + +### Shadows (used sparingly) + +- Boxes: `shadow-sm` (`0 1px 2px 0 rgba(0,0,0,0.05)`) +- Toasts: `shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]` +- Slide-over: `shadow-lg` +- Modal-input: `drop-shadow-sm` + +### Input inset box-shadow system (distinctive) + +Inputs and selects use `box-shadow` instead of `border` — this enables the 4px left dirty-bar indicator: + +```css +/* default */ box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +/* default dark */ inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +/* focus light */ inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +/* focus dark */ inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +/* dirty (same as focus) — set via wire:dirty.class */ +/* disabled / readonly */ box-shadow: none; +``` + +Variant `input-sticky` uses `1px` outer shadow instead of `2px`. + +### Focus ring (buttons, links, checkboxes, non-input) + +`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base` + +## Shapes + +- **Default** — `rounded-sm` (2px). Everything: inputs, buttons, cards, modals, toasts, dropdowns. +- **Coolbox** — `rounded` (4px). Alternate card style with ring-hover. +- **Callouts** — `rounded-lg` (8px). Only exception to the sharp rule. +- **Badges / deprecated badge / pills / avatars** — `rounded-full`. + +Never mix radii within the same view. + +## Components + +All component classes live in `resources/css/utilities.css` as `@utility` blocks, consumed by Blade components under `resources/views/components/`. + +### Forms + +#### Button + +Utility `.button` (`resources/css/utilities.css`): + +``` +flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +Attribute variants (in `app.css`): + +- `button[isHighlighted]` → `text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white` +- `button[isError]` → `text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white` + +Loading: `` — inline `w-4 h-4 dark:text-warning animate-spin` SVG. + +#### Input + +Utility chain `.input-select` → `.input`: + +``` +block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200 dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 focus-visible:outline-none +``` + +Plus the inset box-shadow system (see Elevation). Password variant: `.input[type="password"]` gets `pr-[2.4rem]` for the eye icon. + +**Dirty indicator.** Livewire sets the focus-colored shadow via `wire:dirty.class`: + +```blade +wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" +``` + +Variant `.input-sticky` — same shape, `1px` outer shadow (thinner border). + +#### Select + +Extends `.input-select` + custom SVG dropdown arrow: + +```css +background-image: url("data:image/svg+xml,...stroke='%23000000'..."); +padding-right: 2.5rem; +``` + +Dark mode swaps the SVG stroke to `%23ffffff`. + +#### Checkbox + +Input class: +``` +dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +Container: +``` +form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100 cursor-pointer +``` + +#### Textarea + +Uses the same `input` utility + `font-mono` + dirty-bar via `wire:dirty.class` (identical to input). Optional `@keydown.tab=handleKeydown` inserts 2 spaces on Tab. + +#### Copy-Button + +`resources/views/components/forms/copy-button.blade.php` — readonly `.input` with an absolute-positioned copy icon right-side. Copied state shows a green check (`text-green-500`) for 1 second. Only renders in secure contexts (`window.isSecureContext`). + +### Containers + +#### Box + +Utility `.box`: +``` +relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm +``` + +**Critical child text rule.** On dark hover, background becomes purple `#7317ff` — description text `#737373` disappears. Utilities `.box-title` and `.box-description` include `dark:group-hover:text-white group-hover:text-black` to flip text contrast. + +Variants: `.box-boarding`, `.box-without-bg`, `.box-without-bg-without-border`. + +#### Coolbox + +Utility `.coolbox`: +``` +relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] +``` + +Distinguished by `rounded` (4px, not 2px) and **ring-hover** instead of background change. + +### Status & Badges + +#### Badge base + +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none border border-neutral-200 dark:border-black +``` + +Fill utilities: `.badge-success` (`bg-success`), `.badge-warning` (`bg-warning`), `.badge-error` (`bg-error`). Dashboard variant `.badge-dashboard` is `absolute top-1 right-1 w-2.5 h-2.5`. + +#### Status indicator pattern + +Badge + label side-by-side. Components in `resources/views/components/status/`: + +| Component | Badge | Text color | Loading? | +|---|---|---|---| +| `status/running` | `badge-success` | `text-success` (`#22C55E`) | Swaps to `badge-warning` while checking proxy | +| `status/degraded` | `badge-warning` | `dark:text-warning` (`#fcd452`) | `` + `wire:loading.delay.longer` | +| `status/restarting` | `badge-warning` | `dark:text-warning` | `` | +| `status/stopped` | `badge-error` | `text-error` (`#dc2626`) | `` | + +Layout: `
` → badge → `
{label}
` → optional `({health})` in same color. + +#### Deprecated Badge + +`resources/views/components/deprecated-badge.blade.php`: +``` +px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30 +``` + +#### Tag + +Utility `.tag`: +``` +px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +### Overlays + +#### Callout + +Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`. + +| Type | Background | Border | Title text | Body text | +|---|---|---|---|---| +| warning | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | +| danger | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | +| info | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | +| success | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | + +Icon colors (600 light / 400 dark) match type. + +#### Modal (input variant) + +`resources/views/components/modal.blade.php`: +``` +relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col +``` + +Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon. + +#### Modal Confirmation + +`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password): +``` +relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col +``` + +Uses `` for warning. Password step hidden for OAuth users. + +#### Confirm Modal + +`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog. + +#### Popup / Popup-Small + +Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`. + +#### Slide-Over + +`resources/views/components/slide-over.blade.php`: + +Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10` + +Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200` + +#### Toast + +`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system. + +- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`). +- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`. +- Stacks up to 4 (oldest gets scale 82% then burns). +- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack. +- HTML payload sanitized via `window.sanitizeHTML` (XSS guard). +- Per-toast copy-to-clipboard + close buttons. + +Icon colors: + +| Type | Class | +|---|---| +| success | `text-green-500` | +| info | `text-blue-500` | +| warning | `text-orange-400` | +| danger | `text-red-500` | +| default | `text-gray-800` | + +#### Helper / Tooltip + +`resources/views/components/helper.blade.php`. Icon utility `.info-helper`: +``` +cursor-pointer text-coollabs dark:text-warning +``` + +Popup utility `.info-helper-popup`: +``` +hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words +``` + +Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`). + +### Navigation + +#### Sidebar / Navbar + +Component: `resources/views/components/navbar.blade.php`. + +Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base` + +Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`. + +Utility `.menu-item`: +``` +flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 +``` + +Utility `.menu-item-active`: +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons. + +#### Breadcrumbs + +`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: `
- +
+ +
-
+
    @@ -112,7 +139,7 @@ class="{{ request()->is('/') ? 'menu-item-active menu-item' : 'menu-item' }}"> - Dashboard + Dashboard
  • @@ -127,7 +154,7 @@ class="{{ request()->is('project/*') || request()->is('projects') ? 'menu-item m - Projects + Projects
  • @@ -145,7 +172,7 @@ class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item men - Servers + Servers
  • @@ -157,7 +184,7 @@ class="{{ request()->is('source*') ? 'menu-item-active menu-item' : 'menu-item' - Sources + Sources
  • @@ -170,7 +197,7 @@ class="{{ request()->is('destination*') ? 'menu-item-active menu-item' : 'menu-i stroke-linejoin="round" stroke-width="2" d="M9 4L3 8v12l6-3l6 3l6-4V4l-6 3l-6-3zm-2 8.001V12m4 .001V12m3-2l2 2m2 2l-2-2m0 0l2-2m-2 2l-2 2" /> - Destinations + Destinations
  • @@ -185,7 +212,7 @@ class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item - S3 Storages + S3 Storages
  • @@ -200,7 +227,7 @@ class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'm - Shared Variables + Shared Variables
  • @@ -212,7 +239,7 @@ class="{{ request()->is('notifications*') ? 'menu-item-active menu-item' : 'menu stroke-linejoin="round" stroke-width="2" d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" /> - Notifications + Notifications
  • @@ -224,7 +251,7 @@ class="{{ request()->is('security*') ? 'menu-item-active menu-item' : 'menu-item stroke-linejoin="round" stroke-width="2" d="m16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1-4.069 0l-.301-.301l-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144l-.301-.301a2.877 2.877 0 0 1 0-4.069l2.643-2.643a2.877 2.877 0 0 1 4.069 0zM15 9h.01" /> - Keys & Tokens + Keys & Tokens
  • @@ -239,7 +266,7 @@ class="{{ request()->is('tags*') ? 'menu-item-active menu-item' : 'menu-item' }} - Tags + Tags
  • @can('canAccessTerminal') @@ -254,7 +281,7 @@ class="{{ request()->is('terminal*') ? 'menu-item-active menu-item' : 'menu-item - Terminal + Terminal @endcan @@ -270,7 +297,7 @@ class="{{ request()->is('profile*') ? 'menu-item-active menu-item' : 'menu-item' - Profile + Profile
  • @@ -288,7 +315,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }} - Teams + Teams
  • @if (isCloud() && auth()->user()->isAdmin()) @@ -301,7 +328,7 @@ class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu- stroke-linejoin="round" stroke-width="2" d="M3 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 2h18M7 15h.01M11 15h2" /> - Subscription + Subscription @endif @@ -319,7 +346,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /> - Settings + Settings @endif @@ -333,7 +360,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item - Admin + Admin @endif @@ -341,7 +368,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
    @if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -368,7 +395,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it d="M12 6L8.707 9.293a1 1 0 0 0 0 1.414l.543.543c.69.69 1.81.69 2.5 0l1-1a3.182 3.182 0 0 1 4.5 0l2.25 2.25m-7 3l2 2M15 13l2 2" /> - Sponsor us + Sponsor us @endif @@ -384,7 +411,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it - Feedback + Feedback
@@ -394,15 +421,21 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
@csrf
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 93d6fe413..04cda7d63 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -10,12 +10,19 @@
@@ -40,10 +47,20 @@
-