diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 98cce088b..33558c746 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" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $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", 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 832eb1087..9919a8054 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -153,7 +153,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.'], @@ -322,7 +322,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.'], @@ -488,7 +488,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.'], @@ -650,7 +650,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' => ['dockerfile'], '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.'], @@ -2206,7 +2206,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 84bb4a09d..4f9481794 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; @@ -124,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_nixpacks_args; + private $env_railpack_args; + private $docker_compose; private $docker_compose_base64; @@ -174,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerBuildkitSupported = false; + private bool $dockerBuildxAvailable = false; + private bool $dockerSecretsSupported = false; private bool $skip_build = false; @@ -414,6 +423,7 @@ private function detectBuildKitCapabilities(): void if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); return; @@ -427,8 +437,11 @@ private function detectBuildKitCapabilities(): void if (trim($buildxAvailable) === 'available') { $this->dockerBuildkitSupported = true; + $this->dockerBuildxAvailable = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); } else { + $this->dockerBuildxAvailable = false; + // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag $buildkitTest = instant_remote_process( ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"], @@ -461,6 +474,7 @@ private function detectBuildKitCapabilities(): void } } catch (Exception $e) { $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } @@ -484,8 +498,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 DeploymentException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -938,6 +956,37 @@ private function deploy_nixpacks_buildpack() $this->rolling_update(); } + private function deploy_railpack_buildpack(): void + { + 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) { @@ -1217,11 +1266,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) { @@ -1592,6 +1641,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(); @@ -1644,6 +1694,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(); @@ -1983,7 +2034,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(); @@ -2422,7 +2477,409 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } - private function generate_coolify_env_variables(bool $forBuildTime = false): Collection + 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 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; + } + + /** + * All buildtime variables that must reach the Railpack build. + * + * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare` + * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=` + * on `docker buildx build`. Because Railpack's schema disallows top-level `variables` + * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret` + * channel is the only way user-defined buildtime variables become available to + * commands declared with `useSecrets: true`. + */ + private function railpack_build_variables(): Collection + { + $genericBuildVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get(); + + $railpackVariables = $this->pull_request_id === 0 + ? $this->application->railpack_environment_variables()->get() + : $this->application->railpack_environment_variables_preview()->get(); + + $variables = $genericBuildVariables + ->merge($railpackVariables) + ->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); + } + + $variables = $this->merge_railpack_deploy_apt_packages($variables); + + // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps + // (e.g. SPAs baking the public URL) can read them via /run/secrets/. + foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) { + if (! is_null($value) && $value !== '') { + $variables->put($key, $value); + } + } + + return $variables; + } + + private function merge_railpack_deploy_apt_packages(Collection $variables): Collection + { + $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) + ->filter() + ->values(); + + foreach (['curl', 'wget'] as $package) { + if (! $packages->contains($package)) { + $packages->push($package); + } + } + + $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' ')); + + return $variables; + } + + private function railpack_build_environment_prefix(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return 'env '.$variables + ->map(function ($value, $key) { + return escapeShellValue("{$key}={$value}"); + }) + ->implode(' ').' '; + } + + private function railpack_build_secret_flags(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return ' '.$variables + ->map(function ($value, $key) { + return '--secret '.escapeShellValue("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); + $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); + + 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=\"{$frontendImage}\"" + ." {$cacheArgs}" + ."{$secretFlags}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$imageName}" + ." {$this->workdir}"; + } + + private function decode_railpack_config(string $config, string $source): array + { + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception); + } + + 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 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, + ] + ); + + if (isDev()) { + $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true); + } + + return $this->generated_railpack_config_relative_path(); + } + + private function railpack_prepare_command(?string $configFilePath = null): string + { + $prepare_command = 'railpack prepare'; + + 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) { + $prepare_command .= " {$this->env_railpack_args}"; + } + + if ($configFilePath) { + $prepare_command .= ' --config-file '.escapeShellValue($configFilePath); + } + + $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + + return $prepare_command; + } + + private function ensure_docker_buildx_available_for_railpack(): void + { + if ($this->dockerBuildxAvailable) { + return; + } + + throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.'); + } + + private function build_railpack_image(): void + { + $this->ensure_docker_buildx_available_for_railpack(); + + $railpackVariables = $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], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'), + 'hidden' => true, + 'save' => 'railpack_plan', + ], + ); + + $railpackPlanRaw = $this->saved_outputs->get('railpack_plan'); + if (! empty($railpackPlanRaw)) { + if (isDev()) { + $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true); + } else { + $parsedPlan = json_decode($railpackPlanRaw, true); + if (is_array($parsedPlan)) { + // Strip secrets array to avoid logging variable names in production. + unset($parsedPlan['secrets']); + $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), 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); + } + + $build_command = $this->railpack_build_command($image_name, $railpackVariables); + + $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) { + $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], + ); + } + + protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); $local_branch = $this->branch; @@ -2538,7 +2995,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(); @@ -2550,7 +3007,7 @@ private function generate_env_variables() } } else { $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -3630,7 +4087,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) { @@ -3652,7 +4109,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/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index f89d16912..258b54eed 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -606,7 +606,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 86e407136..0ce1bd1a2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -81,9 +81,11 @@ 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; + 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 5a6f288b3..045ddc6cb 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -94,9 +94,11 @@ 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; + 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 b350538ac..9fe630d63 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -96,9 +96,11 @@ 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; + if (! $this->isStatic) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->isStatic = false; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index c51b27b6a..1dcb7c781 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,9 +2,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\Server; +use App\Models\Service; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -37,15 +42,23 @@ class Add extends Component protected $listeners = ['clearAddEnv' => 'clear']; - protected $rules = [ - 'key' => 'required|string', - 'value' => 'nullable', - 'is_multiline' => 'required|boolean', - 'is_literal' => 'required|boolean', - 'is_runtime' => 'required|boolean', - 'is_buildtime' => 'required|boolean', - 'comment' => 'nullable|string|max:256', - ]; + protected function rules(): array + { + return [ + 'key' => ValidationPatterns::environmentVariableKeyRules(), + 'value' => 'nullable', + 'is_multiline' => 'required|boolean', + 'is_literal' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', + ]; + } + + protected function messages(): array + { + return ValidationPatterns::environmentVariableKeyMessages('key'); + } protected $validationAttributes = [ 'key' => 'key', @@ -85,7 +98,7 @@ public function availableSharedVariables(): array $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view team variables } @@ -116,12 +129,12 @@ public function availableSharedVariables(): array $result['environment'] = $environment->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view environment variables } } } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view project variables } } @@ -131,7 +144,7 @@ public function availableSharedVariables(): array $serverUuid = data_get($this->parameters, 'server_uuid'); if ($serverUuid) { // If we have a specific server_uuid, show variables for that server - $server = \App\Models\Server::where('team_id', $team->id) + $server = Server::where('team_id', $team->id) ->where('uuid', $serverUuid) ->first(); @@ -141,7 +154,7 @@ public function availableSharedVariables(): array $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -149,7 +162,7 @@ public function availableSharedVariables(): array // For application environment variables, try to use the application's destination server $applicationUuid = data_get($this->parameters, 'application_uuid'); if ($applicationUuid) { - $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + $application = Application::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $applicationUuid) ->with('destination.server') ->first(); @@ -160,7 +173,7 @@ public function availableSharedVariables(): array $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -168,7 +181,7 @@ public function availableSharedVariables(): array // For service environment variables, try to use the service's server $serviceUuid = data_get($this->parameters, 'service_uuid'); if ($serviceUuid) { - $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + $service = Service::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $serviceUuid) ->with('server') ->first(); @@ -179,7 +192,7 @@ public function availableSharedVariables(): array $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -192,6 +205,7 @@ public function availableSharedVariables(): array public function submit() { + $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); $this->validate(); $this->dispatch('saveKey', [ 'key' => $this->key, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f250a860b..53b55009e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -2,7 +2,9 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -38,7 +40,7 @@ public function mount() $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); - $resourceWithPreviews = [\App\Models\Application::class]; + $resourceWithPreviews = [Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; @@ -194,7 +196,7 @@ public function submit($data = null) private function updateOrder() { - $variables = parseEnvFormatToArray($this->variables); + $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $order = 1; foreach ($variables as $key => $value) { $env = $this->resource->environment_variables()->where('key', $key)->first(); @@ -206,7 +208,7 @@ private function updateOrder() } if ($this->showPreview) { - $previewVariables = parseEnvFormatToArray($this->variablesPreview); + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); $order = 1; foreach ($previewVariables as $key => $value) { $env = $this->resource->environment_variables_preview()->where('key', $key)->first(); @@ -221,7 +223,7 @@ private function updateOrder() private function handleBulkSubmit() { - $variables = parseEnvFormatToArray($this->variables); + $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $changesMade = false; $errorOccurred = false; @@ -241,7 +243,7 @@ private function handleBulkSubmit() } if ($this->showPreview) { - $previewVariables = parseEnvFormatToArray($this->variablesPreview); + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); // Try to delete removed preview variables $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); @@ -267,6 +269,7 @@ private function handleBulkSubmit() private function handleSingleSubmit($data) { + $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']); $found = $this->resource->environment_variables()->where('key', $data['key'])->first(); if ($found) { $this->dispatch('error', 'Environment variable already exists.'); @@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables) return $variablesToDelete->count(); } + private function normalizeEnvironmentVariables(array $variables): array + { + $normalizedVariables = []; + + foreach ($variables as $key => $data) { + $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key); + + if (array_key_exists($normalizedKey, $normalizedVariables)) { + throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}."); + } + + $normalizedVariables[$normalizedKey] = $data; + } + + return $normalizedVariables; + } + private function updateOrCreateVariables($isPreview, $variables) { $count = 0; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 4e8521f27..26369852e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,12 +2,17 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\Project; +use App\Models\Server; +use App\Models\Service; use App\Models\SharedEnvironmentVariable; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -64,23 +69,31 @@ class Show extends Component 'compose_loaded' => '$refresh', ]; - protected $rules = [ - 'key' => 'required|string', - 'value' => 'nullable', - 'comment' => 'nullable|string|max:256', - 'is_multiline' => 'required|boolean', - 'is_literal' => 'required|boolean', - 'is_shown_once' => 'required|boolean', - 'is_runtime' => 'required|boolean', - 'is_buildtime' => 'required|boolean', - 'real_value' => 'nullable', - 'is_required' => 'required|boolean', - ]; + protected function rules(): array + { + return [ + 'key' => ValidationPatterns::environmentVariableKeyRules(), + 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', + 'is_multiline' => 'required|boolean', + 'is_literal' => 'required|boolean', + 'is_shown_once' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', + 'real_value' => 'nullable', + 'is_required' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return ValidationPatterns::environmentVariableKeyMessages('key'); + } public function mount() { $this->syncData(); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { + if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) { $this->isSharedVariable = true; } $this->parameters = get_route_parameters(); @@ -108,9 +121,11 @@ public function refresh() public function syncData(bool $toModel = false) { if ($toModel) { + $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); + if ($this->isSharedVariable) { $this->validate([ - 'key' => 'required|string', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'nullable', 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', @@ -233,7 +248,7 @@ public function availableSharedVariables(): array $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view team variables } @@ -264,12 +279,12 @@ public function availableSharedVariables(): array $result['environment'] = $environment->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view environment variables } } } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view project variables } } @@ -279,7 +294,7 @@ public function availableSharedVariables(): array $serverUuid = data_get($this->parameters, 'server_uuid'); if ($serverUuid) { // If we have a specific server_uuid, show variables for that server - $server = \App\Models\Server::where('team_id', $team->id) + $server = Server::where('team_id', $team->id) ->where('uuid', $serverUuid) ->first(); @@ -289,7 +304,7 @@ public function availableSharedVariables(): array $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -297,7 +312,7 @@ public function availableSharedVariables(): array // For application environment variables, try to use the application's destination server $applicationUuid = data_get($this->parameters, 'application_uuid'); if ($applicationUuid) { - $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + $application = Application::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $applicationUuid) ->with('destination.server') ->first(); @@ -308,7 +323,7 @@ public function availableSharedVariables(): array $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -316,7 +331,7 @@ public function availableSharedVariables(): array // For service environment variables, try to use the service's server $serviceUuid = data_get($this->parameters, 'service_uuid'); if ($serviceUuid) { - $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + $service = Service::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $serviceUuid) ->with('server') ->first(); @@ -327,7 +342,7 @@ public function availableSharedVariables(): array $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 85e94bfd6..2a364e13f 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.'], @@ -960,7 +960,7 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables() @@ -970,6 +970,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') @@ -988,7 +995,7 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables_preview() @@ -998,6 +1005,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'); @@ -1117,7 +1131,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/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 83212267c..bfb02a470 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -32,6 +34,8 @@ )] class EnvironmentVariable extends BaseModel { + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify']; 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 +113,7 @@ protected static function booted() ]); }); - static::saving(function (EnvironmentVariable $environmentVariable) { + static::saving(function (ModelsEnvironmentVariable $environmentVariable) { $environmentVariable->updateIsShared(); }); } @@ -119,6 +123,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( @@ -188,16 +216,10 @@ protected function isReallyRequired(): Attribute ); } - protected function isNixpacks(): Attribute + protected function isBuildpackControl(): Attribute { return Attribute::make( - get: function () { - if (str($this->key)->startsWith('NIXPACKS_')) { - return true; - } - - return false; - } + get: fn () => self::isBuildpackControlKey($this->key), ); } @@ -349,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null) protected function key(): Attribute { return Attribute::make( - set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value, + set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey( + ValidationPatterns::normalizeEnvironmentVariableKey($value) + ), ); } diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index fa6fd45e0..eadc33ec2 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model @@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model 'value' => 'encrypted', ]; + protected function key(): Attribute + { + return Attribute::make( + set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value), + ); + } + public function team() { return $this->belongsTo(Team::class); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 58dbbe1ac..07926e1cf 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,6 +82,12 @@ class ValidationPatterns */ public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for Docker-compatible environment variable keys. + * Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL. + */ + public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u'; + /** * Pattern for SQL-safe unquoted database identifiers (usernames, database names). * Allows letters, digits, underscore; first char must be letter or underscore. @@ -96,6 +102,67 @@ class ValidationPatterns */ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + /** + * Normalize environment variable keys before validation and storage. + */ + public static function normalizeEnvironmentVariableKey(string $value): string + { + return str($value)->trim()->value; + } + + /** + * Get validation rules for environment variable keys. + */ + public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN; + + return $rules; + } + + /** + * Get validation messages for environment variable key fields. + */ + public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array + { + return [ + "{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Check if a string is a valid environment variable key. + */ + public static function isValidEnvironmentVariableKey(string $value): bool + { + return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; + } + + /** + * Normalize and validate an environment variable key. + */ + public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string + { + $key = self::normalizeEnvironmentVariableKey($value); + + if (! self::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']); + } + + return $key; + } + /** * Get validation rules for database identifier fields (username, database name). * diff --git a/config/constants.php b/config/constants.php index 867cc22d9..dedafa7d5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,6 +5,7 @@ 'version' => '4.1.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', + 'railpack_version' => '0.22.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2a0273e0f..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', @@ -145,5 +161,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(); + } } } 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..78659b457 --- /dev/null +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -0,0 +1,513 @@ +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, + ], + // Multi-language examples (only available on v4.x branch). + [ + 'uuid' => 'railpack-python-flask', + 'name' => 'Railpack Python Flask Example', + 'base_directory' => '/flask', + 'ports_exposes' => '5000', + 'git_branch' => 'v4.x', + 'start_command' => 'flask run --host=0.0.0.0 --port=5000', + ], + [ + 'uuid' => 'railpack-go-gin', + 'name' => 'Railpack Go Gin Example', + 'base_directory' => '/go/gin', + 'ports_exposes' => '3000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-rust', + 'name' => 'Railpack Rust Example', + 'base_directory' => '/rust', + 'ports_exposes' => '8000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel', + 'name' => 'Railpack Laravel Example', + 'base_directory' => '/laravel', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel-pure', + 'name' => 'Railpack Laravel Pure Example', + 'base_directory' => '/laravel-pure', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel-inertia', + 'name' => 'Railpack Laravel Inertia Example', + 'base_directory' => '/laravel-inertia', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-symfony', + 'name' => 'Railpack Symfony Example', + 'base_directory' => '/symfony', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-rails', + 'name' => 'Railpack Ruby on Rails Example', + 'base_directory' => '/rails-example', + 'ports_exposes' => '3000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-elixir-phoenix', + 'name' => 'Railpack Elixir Phoenix Example', + 'base_directory' => '/elixir-phoenix', + 'ports_exposes' => '4000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-bun', + 'name' => 'Railpack Bun Example', + 'base_directory' => '/bun', + 'ports_exposes' => '3000', + 'git_branch' => 'v4.x', + ], + ]; + } + + 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' => $example['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 9c984a5ee..263c5a311 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.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 ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z @@ -25,18 +29,34 @@ ARG DOCKER_COMPOSE_VERSION ARG DOCKER_BUILDX_VERSION ARG PACK_VERSION ARG NIXPACKS_VERSION +ARG RAILPACK_VERSION +ARG MISE_VERSION USER root WORKDIR /artifacts +ENV RAILPACK_VERSION=${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 + +# 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 @@ -46,6 +66,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 af1a1294e..25aada1e1 100644 --- a/openapi.json +++ b/openapi.json @@ -111,6 +111,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -569,6 +570,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1019,6 +1021,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1448,10 +1451,7 @@ "build_pack": { "type": "string", "enum": [ - "nixpacks", - "static", - "dockerfile", - "dockercompose" + "dockerfile" ], "description": "The build pack type." }, @@ -2290,6 +2290,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -12440,6 +12441,7 @@ "description": "Build pack.", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" diff --git a/openapi.yaml b/openapi.yaml index 2532b5797..4597b06f7 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 @@ -375,7 +375,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 @@ -663,7 +663,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 @@ -935,7 +935,7 @@ paths: description: 'The Dockerfile content.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [dockerfile] description: 'The build pack type.' ports_exposes: type: string @@ -1479,7 +1479,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 @@ -7888,6 +7888,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 fe3ef3686..e395a8a7d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -32,6 +32,7 @@ + @@ -226,20 +227,24 @@ 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 - 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 9eb9baea8..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,6 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"> + @@ -60,6 +61,14 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
+ @@ -92,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')
+ @@ -51,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')
@endif @else - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @@ -67,7 +67,7 @@ helper="Make this variable available in the running container at runtime." label="Available at Runtime" /> @if (!$isMagicVariable) - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @if ($is_multiline === false) @endif @else - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @@ -245,7 +245,7 @@ helper="Make this variable available in the running container at runtime." label="Available at Runtime" /> @if (!$isMagicVariable) - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @if ($is_multiline === false) 0 )); do + case "$1" in + --app) APPS+=("$2"); shift 2 ;; + --all) APPS=(__ALL__); shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --no-curl) DO_CURL=0; shift ;; + --extra-env|--both-env) BOTH_ENVS+=("$2"); shift 2 ;; + --build-env) BUILD_ENVS+=("$2"); shift 2 ;; + --runtime-env) RUNTIME_ENVS+=("$2"); shift 2 ;; + --base) API_BASE="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if ! command -v jq >/dev/null; then + echo "jq required" >&2; exit 2 +fi +if ! command -v docker >/dev/null; then + echo "docker required" >&2; exit 2 +fi + +curl_api() { + local method="$1"; shift + local path="$1"; shift + curl -fsS -X "$method" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}${path}" \ + "$@" +} + +if (( ${#APPS[@]} == 0 )); then + APPS=("${DEFAULT_APPS[@]}") +fi + +if [[ "${APPS[0]}" == "__ALL__" ]]; then + mapfile -t APPS < <(curl_api GET /applications | jq -r '.[].uuid' | grep '^railpack-' || true) +fi + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } +fail() { printf '\033[31m[FAIL]\033[0m %s: %s\n' "$1" "$2"; FAILED+=("$1: $2"); } +pass() { printf '\033[32m[ OK ]\033[0m %s: %s\n' "$1" "$2"; } + +upsert_env() { + local app_uuid="$1" key="$2" value="$3" buildtime="$4" runtime="$5" existing + existing=$(curl_api GET "/applications/${app_uuid}/envs" | jq -r --arg k "$key" '.[] | select(.key==$k) | .uuid' | head -1) + local payload + payload=$(jq -nc --arg k "$key" --arg v "$value" --argjson b "$buildtime" --argjson r "$runtime" \ + '{key:$k, value:$v, is_buildtime:$b, is_runtime:$r, is_preview:false}') + if [[ -n "$existing" ]]; then + curl_api PATCH "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} updated (buildtime=${buildtime} runtime=${runtime})" + else + curl_api POST "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} created (buildtime=${buildtime} runtime=${runtime})" + fi +} + +ensure_envs() { + local app_uuid="$1" kv key value + for kv in "${BUILD_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true false + done + for kv in "${RUNTIME_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" false true + done + for kv in "${BOTH_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true true + done +} + +trigger_deploy() { + local app_uuid="$1" + curl_api POST "/applications/${app_uuid}/start?force=true&instant_deploy=true" \ + | jq -r '.deployment_uuid // empty' +} + +wait_for_deploy() { + local dep_uuid="$1" deadline="$2" status + while (( $(date +%s) < deadline )); do + status=$(curl_api GET "/deployments/${dep_uuid}" | jq -r '.status // "unknown"') + case "$status" in + finished) echo finished; return 0 ;; + failed|cancelled) echo "$status"; return 1 ;; + queued|in_progress) sleep 5 ;; + *) sleep 5 ;; + esac + done + echo timeout; return 1 +} + +container_for_app() { + local app_uuid="$1" + docker ps --filter "name=^${app_uuid}-" --format '{{.Names}}' | head -1 +} + +assert_envs_present() { + local container="$1" app_uuid="$2" + local env_dump + env_dump=$(docker exec "$container" env 2>/dev/null || true) + + local missing=() + for required in COOLIFY_FQDN COOLIFY_URL COOLIFY_BRANCH COOLIFY_RESOURCE_UUID COOLIFY_CONTAINER_NAME SOURCE_COMMIT; do + if ! grep -q "^${required}=" <<<"$env_dump"; then + missing+=("$required") + fi + done + + local resource_uuid + resource_uuid=$(grep '^COOLIFY_RESOURCE_UUID=' <<<"$env_dump" | cut -d= -f2- || true) + if [[ "$resource_uuid" != "$app_uuid" ]]; then + missing+=("COOLIFY_RESOURCE_UUID-mismatch(got=${resource_uuid})") + fi + + if (( ${#missing[@]} == 0 )); then + pass "$app_uuid" "runtime envs present (${resource_uuid})" + return 0 + fi + fail "$app_uuid" "missing/incorrect envs: ${missing[*]}" + return 1 +} + +deploy_logs_text() { + local dep_uuid="$1" + curl_api GET "/deployments/${dep_uuid}" | jq -r '(.logs | fromjson? // []) | .[].output' 2>/dev/null +} + +assert_runtime_only_envs() { + local container="$1" app_uuid="$2" + [[ ${#RUNTIME_ENVS[@]} -eq 0 ]] && return 0 + local env_dump key value actual + env_dump=$(docker exec "$container" env 2>/dev/null || true) + for kv in "${RUNTIME_ENVS[@]}"; do + key="${kv%%=*}"; value="${kv#*=}" + if ! grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "runtime-only env ${key} missing at runtime" + return 1 + fi + actual=$(grep "^${key}=" <<<"$env_dump" | head -1 | cut -d= -f2-) + if [[ "$actual" != "$value" ]]; then + fail "$app_uuid" "runtime env ${key} value mismatch (got=${actual} want=${value})" + return 1 + fi + done + pass "$app_uuid" "runtime-only envs present at runtime (${#RUNTIME_ENVS[@]} key(s))" +} + +assert_build_only_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BUILD_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + + for kv in "${BUILD_ENVS[@]}"; do + key="${kv%%=*}" + # Reach build: railpack passes buildtime envs as docker buildx --secret id=KEY + if ! grep -q -- "--secret id=${key}" <<<"$logs"; then + fail "$app_uuid" "build-only env ${key} not seen as --secret in deploy logs" + return 1 + fi + # Must NOT leak to runtime container + if grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "build-only env ${key} LEAKED to runtime container" + return 1 + fi + done + pass "$app_uuid" "build-only envs in build secret + absent at runtime (${#BUILD_ENVS[@]} key(s))" +} + +assert_both_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BOTH_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + for kv in "${BOTH_ENVS[@]}"; do + key="${kv%%=*}" + if [[ "$key" =~ ^RAILPACK_ ]]; then + # RAILPACK_* are buildtime-only by railpack convention; skip runtime check + grep -q -- "--secret id=${key}" <<<"$logs" \ + || { fail "$app_uuid" "${key} not seen in build secrets"; return 1; } + continue + fi + grep -q "^${key}=" <<<"$env_dump" \ + || { fail "$app_uuid" "both-env ${key} missing at runtime"; return 1; } + done + pass "$app_uuid" "both-envs reached runtime (${#BOTH_ENVS[@]} key(s))" +} + +assert_fqdn_responds() { + local app_uuid="$1" + local fqdn + fqdn=$(curl_api GET "/applications/${app_uuid}" | jq -r '.fqdn // empty') + [[ -z "$fqdn" ]] && return 0 + local code + code=$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time 10 "$fqdn" || echo "000") + case "$code" in + 2*|3*|4*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + *) fail "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + esac +} + +run_one() { + local app_uuid="$1" + log "==> ${app_uuid}" + + if ! curl_api GET "/applications/${app_uuid}" >/dev/null 2>&1; then + fail "$app_uuid" "application not found via API (run seeder?)" + return + fi + + ensure_envs "$app_uuid" + + local dep + dep=$(trigger_deploy "$app_uuid") + if [[ -z "$dep" ]]; then + fail "$app_uuid" "no deployment_uuid returned" + return + fi + log " deploy queued: ${dep}" + + local deadline=$(( $(date +%s) + TIMEOUT )) + local result + result=$(wait_for_deploy "$dep" "$deadline") || { + fail "$app_uuid" "deploy ${result}" + return + } + pass "$app_uuid" "deploy ${result}" + + sleep 2 + local container + container=$(container_for_app "$app_uuid") + if [[ -z "$container" ]]; then + fail "$app_uuid" "no running container matching name=^${app_uuid}-" + return + fi + pass "$app_uuid" "container ${container} running" + + assert_envs_present "$container" "$app_uuid" || true + assert_runtime_only_envs "$container" "$app_uuid" || true + assert_build_only_envs "$container" "$app_uuid" "$dep" || true + assert_both_envs "$container" "$app_uuid" "$dep" || true + + if (( DO_CURL )); then + assert_fqdn_responds "$app_uuid" || true + fi +} + +FAILED=() +for app in "${APPS[@]}"; do + run_one "$app" +done + +echo +echo "=== summary ===" +if (( ${#FAILED[@]} == 0 )); then + echo "all apps passed" + exit 0 +fi +printf '%s failure(s):\n' "${#FAILED[@]}" +printf ' - %s\n' "${FAILED[@]}" +exit 1 diff --git a/tests/Feature/Api/RailpackApiTest.php b/tests/Feature/Api/RailpackApiTest.php new file mode 100644 index 000000000..c9dbced4b --- /dev/null +++ b/tests/Feature/Api/RailpackApiTest.php @@ -0,0 +1,345 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'railpack-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function railpackApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function makeRailpackApp(array $overrides = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'railpack', + ], $overrides)); +} + +describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () { + test('rejects unsupported build_pack at controller layer', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'totally-bogus', + ]); + + $response->assertStatus(422); + }); + + test('switching from dockerfile to railpack clears dockerfile fields', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:20', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->dockerfile)->toBeNull(); + // NOTE: dockerfile_location is normalized to '/Dockerfile' by the model + // mutator when set to null, so we cannot assert it becomes null here. + expect($app->dockerfile_target_build)->toBeNull(); + expect((bool) $app->custom_healthcheck_found)->toBeFalse(); + }); + + test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx", + ]); + + $app->environment_variables()->createMany([ + ['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false], + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->docker_compose_domains)->toBeNull(); + expect($app->docker_compose_raw)->toBeNull(); + expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); + }); + + test('install/build/start commands persist for railpack apps', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->install_command)->toBe('npm ci'); + expect($app->build_command)->toBe('npm run build'); + expect($app->start_command)->toBe('node server.js'); + }); +}); + +describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () { + test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ]); + + $response->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables)->toHaveCount(1); + expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION'); + expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0); + }); + + test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $runtime = $app->runtime_environment_variables; + expect($runtime->pluck('key')->all())->toBe(['APP_ENV']); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => true, + ])->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables_preview)->toHaveCount(1); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('buildtime-only env has is_buildtime=true and is_runtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'API_KEY', + 'value' => 'sekret', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'API_KEY')->first(); + expect($env)->not->toBeNull(); + expect((bool) $env->is_buildtime)->toBeTrue(); + expect((bool) $env->is_runtime)->toBeFalse(); + // Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var) + // but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone. + expect($env->resourceable_id)->toBe($app->id); + }); + + test('runtime-only env has is_runtime=true and is_buildtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'LOG_LEVEL', + 'value' => 'debug', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first(); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); + + test('railpack build variables collection includes only is_buildtime=true entries', function () { + // Sanity check the underlying query used by the deploy job: railpack_build_variables() + // pulls $application->environment_variables()->where('is_buildtime', true)->get() + // (see ApplicationDeploymentJob::railpack_build_variables). + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'BUILD_ARG', + 'value' => 'in-build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RUNTIME_ARG', + 'value' => 'in-runtime', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all(); + expect($buildtime)->toContain('BUILD_ARG'); + expect($buildtime)->not->toContain('RUNTIME_ARG'); + }); + + test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () { + // Documents generate_coolify_env_variables() override behavior: + // it skips generation when application->environment_variables already has the key. + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'COOLIFY_FQDN', + 'value' => 'overridden.example.com', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first(); + expect($env)->not->toBeNull(); + expect($env->value)->toBe('overridden.example.com'); + // Confirm the model relation used by override-skip logic finds it + expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse(); + }); + + test('is_literal flag persists on create', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_LITERAL_FLAG', + 'value' => '$NOT_INTERPOLATED', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'is_literal' => true, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first(); + expect((bool) $env->is_literal)->toBeTrue(); + }); + + test('PATCH env updates buildtime/runtime flags', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v1', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v2', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_multiline' => false, + 'is_shown_once' => false, + ])->assertStatus(201); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first(); + expect($env->value)->toBe('v2'); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); +}); diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index b6b535a76..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, ]); @@ -117,6 +120,87 @@ 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', + ]); + + // 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(); + + 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 () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php new file mode 100644 index 000000000..d728f5ad7 --- /dev/null +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -0,0 +1,388 @@ +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='); +}); + +it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'railpack', + 'fqdn' => 'https://railpack.example.com', + 'install_command' => 'pnpm install --frozen-lockfile', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RUNTIME_ONLY', + 'value' => 'runtime', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + 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->fresh(), $server, [ + 'build_pack' => 'railpack', + 'branch' => 'main', + ]); + + /** @var Collection $variables */ + $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); + + expect($variables->get('APP_ENV'))->toBe('production'); + expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); + expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); + expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); +}); + +it('builds preview railpack variables without leaking stale nixpacks vars', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'railpack', + 'fqdn' => 'https://railpack.example.com', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_BUILD_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_RUNTIME_ONLY', + 'value' => 'runtime', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'build_pack' => 'railpack', + 'branch' => 'feature/railpack', + 'pull_request_id' => 123, + ]); + + /** @var Collection $variables */ + $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); + + expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); + expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); + expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); +}); diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php new file mode 100644 index 000000000..ac5cda4b9 --- /dev/null +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -0,0 +1,86 @@ +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); +}); + +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/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php new file mode 100644 index 000000000..59e8a82e0 --- /dev/null +++ b/tests/Feature/ApplicationRailpackTest.php @@ -0,0 +1,150 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->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' => $this->environment->id, + 'build_pack' => 'railpack', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns true for nixpacks', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'nixpacks', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns false for dockerfile', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockerfile', + ]); + + expect($application->could_set_build_commands())->toBeFalse(); + }); + + test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->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 () { + $application = Application::factory()->create([ + 'environment_id' => $this->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 () { + $application = Application::factory()->create([ + 'environment_id' => $this->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/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/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, diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php new file mode 100644 index 000000000..59646a804 --- /dev/null +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -0,0 +1,141 @@ +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(); + + $examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid'); + expect($applications->every( + fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['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'); + $pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask'); + $goGin = $applications->firstWhere('uuid', 'railpack-go-gin'); + $rust = $applications->firstWhere('uuid', 'railpack-rust'); + + 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(); + + expect($pythonFlask) + ->not->toBeNull() + ->and($pythonFlask->ports_exposes)->toBe('5000') + ->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000'); + + expect($goGin) + ->not->toBeNull() + ->and($goGin->ports_exposes)->toBe('3000'); + + expect($rust) + ->not->toBeNull() + ->and($rust->ports_exposes)->toBe('8000'); +}); + +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/Feature/EnvironmentVariableKeyValidationTest.php b/tests/Feature/EnvironmentVariableKeyValidationTest.php new file mode 100644 index 000000000..4f41ed3d6 --- /dev/null +++ b/tests/Feature/EnvironmentVariableKeyValidationTest.php @@ -0,0 +1,39 @@ +set('key', 'BAD=KEY') + ->set('value', 'value') + ->call('submit') + ->assertHasErrors(['key' => 'regex']); +}); + +it('allows Docker-compatible environment variable keys in the add form', function (string $key) { + Livewire::test(Add::class) + ->set('key', $key) + ->set('value', 'value') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('saveKey', function ($event, array $data) use ($key) { + return data_get($data, 'key') === $key || data_get($data, '0.key') === $key; + }); +})->with([ + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', +]); + +it('trims surrounding whitespace in environment variable keys in the add form', function () { + Livewire::test(Add::class) + ->set('key', ' node.name ') + ->set('value', 'value') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('saveKey', function ($event, array $data) { + return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name'; + }); +}); diff --git a/tests/Feature/Livewire/RailpackLivewireUiTest.php b/tests/Feature/Livewire/RailpackLivewireUiTest.php new file mode 100644 index 000000000..b21037b11 --- /dev/null +++ b/tests/Feature/Livewire/RailpackLivewireUiTest.php @@ -0,0 +1,121 @@ +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]); +}); + +describe('PublicGitRepository port handling for railpack', function () { + test('switching to railpack resets port to 3000 when not static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'dockerfile') + ->assertSet('port', 3000) + ->set('build_pack', 'railpack') + ->assertSet('port', 3000); + }); + + test('switching to railpack preserves port when isStatic is true', function () { + $component = Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('isStatic', true) + ->call('instantSave'); + + // After instantSave with isStatic=true, port becomes 80 + $component->assertSet('port', 80); + + // Switching from nixpacks to railpack should NOT clobber port back to 3000 + $component->set('build_pack', 'railpack') + ->assertSet('port', 80); + }); + + test('switching to static sets port to 80 and disables show_is_static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'static') + ->assertSet('port', 80) + ->assertSet('isStatic', false) + ->assertSet('show_is_static', false); + }); +}); + +describe('General view railpack helper text', function () { + beforeEach(function () { + $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('railpack app shows railpack.json helper text and not nixpacks.toml', 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.json') + ->assertDontSee('nixpacks.toml'); + }); + + test('nixpacks app shows nixpacks.toml helper text and not railpack.json', 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() + ->assertSee('nixpacks.toml') + ->assertDontSee('railpack.json'); + }); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php new file mode 100644 index 000000000..f1bcdcb65 --- /dev/null +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -0,0 +1,51 @@ +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 (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..e1449a59e --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -0,0 +1,249 @@ +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'", + 'force_rebuild' => false, + 'addHosts' => '', + 'secrets_hash_key' => 'testing-app-key', + ] 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 railpack env for install and cli flags for build/start overrides', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + '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, + $reflection, + 'railpack_prepare_command', + ['.coolify/railpack.generated.json'], + ); + + expect($command)->toContain('railpack prepare'); + 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')); + 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('RAILPACK_BUILD_CMD='); + expect($command)->not->toContain('RAILPACK_START_CMD='); +}); + +it('fails fast when docker buildx is unavailable for railpack builds', function () { + [$job, $reflection] = makeRailpackDeploymentJob(); + + $dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable'); + $dockerBuildxAvailableProperty->setAccessible(true); + $dockerBuildxAvailableProperty->setValue($job, false); + + expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack')) + ->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin'); +}); + +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', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + '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("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + 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=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'"); + expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'"); + expect($command)->toContain(' --build-arg secrets-hash='); + expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"'); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php new file mode 100644 index 000000000..1dda8b8c3 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -0,0 +1,267 @@ +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); + + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); + + $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', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + ]); + expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); + expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); + expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->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'); + + $previewQuery = Mockery::mock(); + $previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); + $previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $previewQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery); + + $railpackPreviewQuery = Mockery::mock(); + $railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue])); + $application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); + + $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', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + ]); +}); + +it('merges coolify env variables into railpack build variables', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $userVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $userVar->forceFill([ + 'key' => 'MY_BUILD_VAR', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello'); + + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables') + ->with(true) + ->andReturn(collect([ + 'COOLIFY_URL' => 'https://app.example.com', + 'COOLIFY_FQDN' => 'app.example.com', + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'app-uuid', + 'SOURCE_COMMIT' => 'abc123', + 'EMPTY_VAR' => '', + 'NULL_VAR' => null, + ])); + + $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); + + expect($variables->all())->toBe([ + 'MY_BUILD_VAR' => 'hello', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'COOLIFY_URL' => 'https://app.example.com', + 'COOLIFY_FQDN' => 'app.example.com', + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'app-uuid', + 'SOURCE_COMMIT' => 'abc123', + ]); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); + expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->not->toContain('EMPTY_VAR'); + expect($envArgs)->not->toContain('NULL_VAR'); +}); + +it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $deployPackages->forceFill([ + 'key' => 'RAILPACK_DEPLOY_APT_PACKAGES', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl'); + + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); + + $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); + + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget'); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'"); +}); diff --git a/tests/Unit/EnvironmentVariableBuildpackControlTest.php b/tests/Unit/EnvironmentVariableBuildpackControlTest.php new file mode 100644 index 000000000..c24956c0d --- /dev/null +++ b/tests/Unit/EnvironmentVariableBuildpackControlTest.php @@ -0,0 +1,74 @@ +key = 'NIXPACKS_NODE_VERSION'; + + expect($env->is_buildpack_control)->toBeTrue(); +}); + +it('flags RAILPACK_ keys as buildpack control variables', function () { + $env = new EnvironmentVariable; + $env->key = 'RAILPACK_NODE_VERSION'; + + expect($env->is_buildpack_control)->toBeTrue(); +}); + +it('does not flag user-defined keys as buildpack control variables', function () { + $env = new EnvironmentVariable; + $env->key = 'MY_BUILD_VAR'; + + expect($env->is_buildpack_control)->toBeFalse(); +}); + +it('does not flag empty key as buildpack control variable', function () { + $env = new EnvironmentVariable; + + expect($env->is_buildpack_control)->toBeFalse(); +}); + +it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () { + $env = new EnvironmentVariable; + + expect($env->getAppends())->toContain('is_buildpack_control'); + expect($env->getAppends())->not->toContain('is_nixpacks'); +}); + +it('normalizes environment variable keys before storing them on the model', function () { + $env = new EnvironmentVariable; + $env->key = ' node.name '; + + expect($env->key)->toBe('node.name'); +}); + +it('allows Docker-compatible environment variable keys on the model', function (string $key) { + $env = new EnvironmentVariable; + $env->key = $key; + + expect($env->key)->toBe($key); +})->with([ + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', + 'semicolon' => 'BAD;KEY', +]); + +it('rejects environment variable keys Docker cannot represent on the model', function () { + $env = new EnvironmentVariable; + + expect(function () use ($env) { + $env->key = 'BAD=KEY'; + })->toThrow(InvalidArgumentException::class, 'Docker-compatible'); +}); + +it('rejects shared environment variable keys Docker cannot represent on the model', function () { + $env = new SharedEnvironmentVariable; + + expect(function () use ($env) { + $env->key = 'BAD=KEY'; + })->toThrow(InvalidArgumentException::class, 'Docker-compatible'); +}); diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 9ecffe46d..a959b18d5 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -1,5 +1,6 @@ toContain('nullable') ->not->toContain('required'); }); + +it('accepts Docker-compatible environment variable keys', function (string $key) { + expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue(); +})->with([ + 'letters' => 'APP_ENV', + 'leading underscore' => '_TOKEN', + 'railpack control variable' => 'RAILPACK_NODE_VERSION', + 'digits after first character' => 'NODE_VERSION_20', + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', + 'semicolon' => 'BAD;KEY', + 'space' => 'BAD KEY', +]); + +it('rejects environment variable keys Docker cannot represent', function (string $key) { + expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse(); +})->with([ + 'equals' => 'BAD=KEY', + 'empty' => '', +]); + +it('generates environment variable key rules with correct defaults', function () { + $rules = ValidationPatterns::environmentVariableKeyRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN); +}); + +it('normalizes environment variable keys by trimming surrounding whitespace', function () { + expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name'); +}); + +it('normalizes environment variable keys before model validation', function () { + $environmentVariable = new EnvironmentVariable; + $environmentVariable->key = ' APP_ENV '; + + expect($environmentVariable->key)->toBe('APP_ENV'); +});