Merge branch 'next' into add-emqx-as-a-service-template
This commit is contained in:
commit
3898860478
103 changed files with 6033 additions and 1342 deletions
|
|
@ -36,10 +36,11 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
: getCurrentApplicationContainerStatus($server, $application->id, 0);
|
||||
|
||||
$containersToStop = $containers->pluck('Names')->toArray();
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,15 @@ public function handle(Application $application, Server $server)
|
|||
}
|
||||
try {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ enum BuildPackTypes: string
|
|||
case STATIC = 'static';
|
||||
case DOCKERFILE = 'dockerfile';
|
||||
case DOCKERCOMPOSE = 'dockercompose';
|
||||
case RAILPACK = 'railpack';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Actions\Application\LoadComposeFile;
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
|
|
@ -18,7 +17,6 @@
|
|||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
|
|
@ -155,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.'],
|
||||
|
|
@ -324,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.'],
|
||||
|
|
@ -490,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.'],
|
||||
|
|
@ -652,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.'],
|
||||
|
|
@ -899,105 +897,6 @@ public function create_dockerimage_application(Request $request)
|
|||
return $this->create_application($request, 'dockerimage');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
|
||||
*/
|
||||
#[OA\Post(
|
||||
summary: 'Create (Docker Compose)',
|
||||
description: 'Deprecated: Use POST /api/v1/services instead.',
|
||||
path: '/applications/dockercompose',
|
||||
operationId: 'create-dockercompose-application',
|
||||
deprecated: true,
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Application object that needs to be created.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
|
||||
properties: [
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
'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.'],
|
||||
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
|
||||
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The application name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The application description.'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Application created successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_dockercompose_application(Request $request)
|
||||
{
|
||||
return $this->create_application($request, 'dockercompose');
|
||||
}
|
||||
|
||||
private function create_application(Request $request, $type)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
|
@ -1080,6 +979,9 @@ private function create_application(Request $request, $type)
|
|||
],
|
||||
], 422);
|
||||
}
|
||||
$request->merge([
|
||||
'custom_nginx_configuration' => $customNginxConfiguration,
|
||||
]);
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
|
||||
|
|
@ -2005,97 +1907,6 @@ private function create_application(Request $request, $type)
|
|||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockercompose') {
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
if (! $request->has('name')) {
|
||||
$request->offsetSet('name', 'service'.new Cuid2);
|
||||
}
|
||||
$validationRules = [
|
||||
'docker_compose_raw' => 'string|required',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validator = customApiValidator($request->all(), $validationRules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
$return = $this->validateDataApplications($request, $server);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
if (! isBase64Encoded($request->docker_compose_raw)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
|
||||
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
|
||||
$service = new Service;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$service->fill($request->only($allowedFields));
|
||||
|
||||
$service->docker_compose_raw = $dockerComposeRaw;
|
||||
$service->environment_id = $environment->id;
|
||||
$service->server_id = $server->id;
|
||||
$service->destination_id = $destination->id;
|
||||
$service->destination_type = $destination->getMorphClass();
|
||||
if (isset($isContainerLabelEscapeEnabled)) {
|
||||
$service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
|
||||
}
|
||||
$service->save();
|
||||
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.application.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => data_get($service, 'uuid'),
|
||||
'service_name' => data_get($service, 'name'),
|
||||
'application_type' => $type,
|
||||
'instant_deploy' => (bool) ($instantDeploy ?? false),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($service, 'uuid'),
|
||||
'domains' => data_get($service, 'domains'),
|
||||
]))->setStatusCode(201);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Invalid type.'], 400);
|
||||
|
|
@ -2398,7 +2209,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.'],
|
||||
|
|
@ -2589,7 +2400,7 @@ public function update_by_uuid(Request $request)
|
|||
}
|
||||
}
|
||||
}
|
||||
if ($request->has('custom_nginx_configuration')) {
|
||||
if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
|
||||
if (! isBase64Encoded($request->custom_nginx_configuration)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
|
|
@ -2607,6 +2418,9 @@ public function update_by_uuid(Request $request)
|
|||
],
|
||||
], 422);
|
||||
}
|
||||
$request->merge([
|
||||
'custom_nginx_configuration' => $customNginxConfiguration,
|
||||
]);
|
||||
}
|
||||
$return = $this->validateDataApplications($request, $server);
|
||||
if ($return instanceof JsonResponse) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
|
@ -29,20 +28,36 @@ public function handle(): void
|
|||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now())
|
||||
->where('expires_at', '<=', now()->addDay())
|
||||
->whereNull('api_token_expiration_warning_sent_at')
|
||||
->where('tokenable_type', User::class)
|
||||
->chunkById(100, function ($tokens) {
|
||||
foreach ($tokens as $token) {
|
||||
if (! $token->team_id) {
|
||||
continue;
|
||||
}
|
||||
RateLimiter::attempt(
|
||||
'api-token-expiring:'.$token->id,
|
||||
$maxAttempts = 0,
|
||||
function () use ($token) {
|
||||
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
|
||||
},
|
||||
$decaySeconds = 7 * 24 * 3600,
|
||||
);
|
||||
|
||||
$team = Team::find($token->team_id);
|
||||
if (! $team) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warningSentAt = now();
|
||||
|
||||
$team->notify(new ApiTokenExpiringNotification($token));
|
||||
|
||||
$markedAsSent = PersonalAccessToken::query()
|
||||
->whereKey($token->getKey())
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now())
|
||||
->where('expires_at', '<=', now()->addDay())
|
||||
->whereNull('api_token_expiration_warning_sent_at')
|
||||
->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||
|
||||
if ($markedAsSent !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -519,11 +537,6 @@ private function post_deployment()
|
|||
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$this->application->isConfigurationChanged(true);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deploy_simple_dockerfile()
|
||||
|
|
@ -938,6 +951,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) {
|
||||
|
|
@ -1105,12 +1149,15 @@ private function generate_image_names()
|
|||
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
|
||||
}
|
||||
} elseif ($this->pull_request_id !== 0) {
|
||||
$previewImageTag = $this->previewImageTag();
|
||||
$previewBuildImageTag = $this->previewImageTag(build: true);
|
||||
|
||||
if ($this->application->docker_registry_image_name) {
|
||||
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
|
||||
$this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}";
|
||||
$this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
|
||||
$this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
|
||||
} else {
|
||||
$this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build";
|
||||
$this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}";
|
||||
$this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
|
||||
$this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
|
||||
}
|
||||
} else {
|
||||
$this->dockerImageTag = str($this->commit)->substr(0, 128);
|
||||
|
|
@ -1127,6 +1174,27 @@ private function generate_image_names()
|
|||
}
|
||||
}
|
||||
|
||||
private function previewImageTag(bool $build = false): string
|
||||
{
|
||||
$prefix = "pr-{$this->pull_request_id}-";
|
||||
$suffix = $build ? '-build' : '';
|
||||
$maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
|
||||
$commitSource = ($this->commit === 'HEAD' || blank($this->commit))
|
||||
? $this->deployment_uuid
|
||||
: $this->commit;
|
||||
|
||||
$commit = Str::of($commitSource)
|
||||
->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
|
||||
->substr(0, $maxCommitLength)
|
||||
->toString();
|
||||
|
||||
if ($commit === '') {
|
||||
$commit = 'HEAD';
|
||||
}
|
||||
|
||||
return "{$prefix}{$commit}{$suffix}";
|
||||
}
|
||||
|
||||
private function just_restart()
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
|
||||
|
|
@ -1165,8 +1233,9 @@ private function should_skip_build()
|
|||
|
||||
return true;
|
||||
}
|
||||
if (! $this->application->isConfigurationChanged()) {
|
||||
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
|
||||
if (! $configurationDiff->requiresBuild()) {
|
||||
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$this->skip_build = true;
|
||||
$this->generate_compose_file();
|
||||
|
||||
|
|
@ -1178,7 +1247,7 @@ private function should_skip_build()
|
|||
|
||||
return true;
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.');
|
||||
$this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
|
||||
}
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
|
||||
|
|
@ -1217,11 +1286,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 +1661,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 +1714,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 +2054,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 +2497,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/<KEY>.
|
||||
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 +3015,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 +3027,7 @@ private function generate_env_variables()
|
|||
}
|
||||
} else {
|
||||
$envs = $this->application->environment_variables_preview()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->withoutBuildpackControlVariables()
|
||||
->where('is_buildtime', true)
|
||||
->get();
|
||||
|
||||
|
|
@ -3309,14 +3786,15 @@ private function build_image()
|
|||
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
|
||||
{
|
||||
try {
|
||||
$timeout = isDev() ? 1 : 30;
|
||||
$timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
|
||||
|
||||
if ($skipRemove) {
|
||||
$this->execute_remote_command(
|
||||
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
||||
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
||||
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||
);
|
||||
}
|
||||
|
|
@ -3630,7 +4108,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 +4130,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) {
|
||||
|
|
@ -4256,6 +4734,12 @@ private function handleSuccessfulDeployment(): void
|
|||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Str;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -35,7 +36,7 @@ public function handle(): void
|
|||
$data = data_get($this->event, 'data.object');
|
||||
switch ($type) {
|
||||
case 'radar.early_fraud_warning.created':
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$id = data_get($data, 'id');
|
||||
$charge = data_get($data, 'charge');
|
||||
if ($charge) {
|
||||
|
|
@ -94,12 +95,12 @@ public function handle(): void
|
|||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
break;
|
||||
}
|
||||
|
||||
if ($subscription->stripe_subscription_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
|
@ -154,7 +155,7 @@ public function handle(): void
|
|||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
break;
|
||||
}
|
||||
$team = data_get($subscription, 'team');
|
||||
if (! $team) {
|
||||
|
|
@ -165,7 +166,7 @@ public function handle(): void
|
|||
// Verify payment status with Stripe API before sending failure notification
|
||||
if ($paymentIntentId) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||
|
||||
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
||||
|
|
@ -190,7 +191,7 @@ public function handle(): void
|
|||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
break;
|
||||
}
|
||||
if ($subscription->stripe_invoice_paid) {
|
||||
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
|
||||
|
|
@ -334,7 +335,7 @@ public function handle(): void
|
|||
}
|
||||
} else {
|
||||
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -47,14 +47,10 @@ public function submit()
|
|||
try {
|
||||
$this->rateLimit(10);
|
||||
$this->validate();
|
||||
$firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
|
||||
auth()->user()->fill([
|
||||
'password' => Hash::make($this->password),
|
||||
'force_password_reset' => false,
|
||||
])->save();
|
||||
if ($firstLogin) {
|
||||
send_internal_notification('First login for '.auth()->user()->email);
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -61,6 +63,9 @@ class Advanced extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $gpuOptions = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $stopGracePeriod = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isBuildServerEnabled = false;
|
||||
|
||||
|
|
@ -145,6 +150,10 @@ public function syncData(bool $toModel = false)
|
|||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||
}
|
||||
|
||||
// Load stop_grace_period separately since it has its own save handler
|
||||
// Convert null to empty string to prevent dirty detection issues
|
||||
$this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
|
||||
}
|
||||
|
||||
private function resetDefaultLabels()
|
||||
|
|
@ -210,6 +219,7 @@ public function submit()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -228,6 +238,7 @@ public function saveCustomName()
|
|||
if (is_null($this->customInternalName)) {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -247,6 +258,32 @@ public function saveCustomName()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveStopGracePeriod()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$validated = Validator::make(
|
||||
['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
|
||||
['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
|
||||
[],
|
||||
['stopGracePeriod' => 'stop grace period']
|
||||
)->validate();
|
||||
|
||||
$this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
|
||||
? null
|
||||
: (int) $validated['stopGracePeriod'];
|
||||
$this->application->settings->save();
|
||||
|
||||
$this->dispatch('success', 'Stop grace period updated.');
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -338,10 +338,11 @@ public function addDockerImagePreview()
|
|||
private function stopContainers(array $containers, $server)
|
||||
{
|
||||
$containersToStop = collect($containers)->pluck('Names')->toArray();
|
||||
$timeout = $this->application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ public function setPrivateKey(int $privateKeyId)
|
|||
$this->application->refresh();
|
||||
$this->privateKeyName = $this->application->private_key->name;
|
||||
$this->dispatch('success', 'Private key updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -124,6 +125,7 @@ public function submit()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Application source updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -12,15 +12,20 @@
|
|||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ConfigurationChecker extends Component
|
||||
{
|
||||
public bool $isConfigurationChanged = false;
|
||||
|
||||
public array $configurationDiff = [];
|
||||
|
||||
public array $groupedConfigurationChanges = [];
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
|
|
@ -30,18 +35,36 @@ public function getListeners()
|
|||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.configuration-checker');
|
||||
}
|
||||
|
||||
public function configurationChanged()
|
||||
public function refreshConfigurationChanges(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
if ($this->resource instanceof Application) {
|
||||
$diff = $this->resource->pendingDeploymentConfigurationDiff();
|
||||
$this->isConfigurationChanged = $diff->isChanged();
|
||||
$this->configurationDiff = $diff->toArray();
|
||||
$this->groupedConfigurationChanges = $diff->groupedChanges();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
|
||||
$this->configurationDiff = [];
|
||||
$this->groupedConfigurationChanges = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiff;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
|
|
@ -39,7 +42,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.'],
|
||||
|
|
@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute
|
|||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return '/Dockerfile';
|
||||
} else {
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
|
||||
}
|
||||
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -886,8 +889,8 @@ public function status(): Attribute
|
|||
public function customNginxConfiguration(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: fn ($value) => base64_encode($value),
|
||||
get: fn ($value) => base64_decode($value),
|
||||
set: fn ($value) => is_null($value) ? null : base64_encode($value),
|
||||
get: fn ($value) => is_null($value) ? null : base64_decode($value),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -960,7 +963,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 +973,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 +998,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 +1008,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');
|
||||
|
|
@ -1045,7 +1062,7 @@ public function isDeploymentInprogress()
|
|||
|
||||
public function get_last_successful_deployment()
|
||||
{
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
}
|
||||
|
||||
public function get_last_days_deployments()
|
||||
|
|
@ -1117,7 +1134,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;
|
||||
}
|
||||
|
||||
|
|
@ -1156,33 +1173,92 @@ public function isLogDrainEnabled()
|
|||
}
|
||||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$configurationDiff = $this->pendingDeploymentConfigurationDiff();
|
||||
|
||||
if ($save) {
|
||||
$this->markDeploymentConfigurationApplied();
|
||||
}
|
||||
|
||||
return $configurationDiff->isChanged();
|
||||
}
|
||||
|
||||
public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
|
||||
{
|
||||
$currentSnapshot = $this->deploymentConfigurationSnapshot();
|
||||
$lastDeployment = $this->get_last_successful_deployment();
|
||||
|
||||
if ($lastDeployment?->configuration_snapshot) {
|
||||
return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot);
|
||||
}
|
||||
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
if ($oldConfigHash === null) {
|
||||
return ConfigurationDiff::legacy(true);
|
||||
}
|
||||
|
||||
return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash());
|
||||
}
|
||||
|
||||
public function hasPendingDeploymentConfigurationChanges(): bool
|
||||
{
|
||||
return $this->pendingDeploymentConfigurationDiff()->isChanged();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationSnapshot(): array
|
||||
{
|
||||
return (new ApplicationConfigurationSnapshot($this))->toArray();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationHash(): string
|
||||
{
|
||||
return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot());
|
||||
}
|
||||
|
||||
public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void
|
||||
{
|
||||
$this->refresh();
|
||||
|
||||
if (! $deployment) {
|
||||
$this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->deploymentConfigurationSnapshot();
|
||||
$hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot);
|
||||
|
||||
$previousDeployment = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $this->id)
|
||||
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
||||
->where('pull_request_id', $deployment->pull_request_id ?? 0)
|
||||
->where('id', '!=', $deployment->id)
|
||||
->whereNotNull('configuration_snapshot')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$deployment->update([
|
||||
'configuration_hash' => $hash,
|
||||
'configuration_snapshot' => $snapshot,
|
||||
'configuration_diff' => $previousDeployment?->configuration_snapshot
|
||||
? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray()
|
||||
: null,
|
||||
]);
|
||||
|
||||
$this->forceFill(['config_hash' => $hash])->save();
|
||||
}
|
||||
|
||||
private function legacyConfigurationHash(): string
|
||||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
} else {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
if ($oldConfigHash === null) {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if ($oldConfigHash === $newConfigHash) {
|
||||
return false;
|
||||
} else {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return md5($newConfigHash);
|
||||
}
|
||||
|
||||
public function customRepository()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
'deployment_uuid' => ['type' => 'string'],
|
||||
'pull_request_id' => ['type' => 'integer'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
|
||||
'configuration_hash' => ['type' => 'string', 'nullable' => true],
|
||||
'configuration_snapshot' => ['type' => 'object', 'nullable' => true],
|
||||
'configuration_diff' => ['type' => 'object', 'nullable' => true],
|
||||
'force_rebuild' => ['type' => 'boolean'],
|
||||
'commit' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
|
|
@ -45,6 +48,9 @@ class ApplicationDeploymentQueue extends Model
|
|||
'deployment_uuid',
|
||||
'pull_request_id',
|
||||
'docker_registry_image_tag',
|
||||
'configuration_hash',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
'force_rebuild',
|
||||
'commit',
|
||||
'status',
|
||||
|
|
@ -71,6 +77,8 @@ class ApplicationDeploymentQueue extends Model
|
|||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'configuration_snapshot' => 'array',
|
||||
'configuration_diff' => 'array',
|
||||
];
|
||||
|
||||
public function application()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class ApplicationSetting extends Model
|
|||
'is_git_lfs_enabled' => 'boolean',
|
||||
'is_git_shallow_clone_enabled' => 'boolean',
|
||||
'docker_images_to_keep' => 'integer',
|
||||
'stop_grace_period' => 'integer',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -64,8 +65,30 @@ class ApplicationSetting extends Model
|
|||
'inject_build_args_to_dockerfile',
|
||||
'include_source_commit_in_build',
|
||||
'docker_images_to_keep',
|
||||
'stop_grace_period',
|
||||
];
|
||||
|
||||
public function stopGracePeriodSeconds(): int
|
||||
{
|
||||
if (
|
||||
$this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS &&
|
||||
$this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS
|
||||
) {
|
||||
return $this->stop_grace_period;
|
||||
}
|
||||
|
||||
return DEFAULT_STOP_GRACE_PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
public function deploymentStopGracePeriodSeconds(): int
|
||||
{
|
||||
if (isDev() && $this->stop_grace_period === null) {
|
||||
return MIN_STOP_GRACE_PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
return $this->stopGracePeriodSeconds();
|
||||
}
|
||||
|
||||
public function isStatic(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
|||
'token',
|
||||
'abilities',
|
||||
'expires_at',
|
||||
'api_token_expiration_warning_sent_at',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_token_expiration_warning_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ApplicationConfigurationSnapshot
|
||||
{
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public function __construct(protected Application $application) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$this->application->load('settings');
|
||||
|
||||
return [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'resource_type' => Application::class,
|
||||
'resource_id' => $this->application->id,
|
||||
'sections' => [
|
||||
'source' => [
|
||||
'label' => 'Source',
|
||||
'items' => $this->sourceItems(),
|
||||
],
|
||||
'build' => [
|
||||
'label' => 'Build',
|
||||
'items' => $this->buildItems(),
|
||||
],
|
||||
'runtime' => [
|
||||
'label' => 'Runtime',
|
||||
'items' => $this->runtimeItems(),
|
||||
],
|
||||
'domains' => [
|
||||
'label' => 'Domains & Proxy',
|
||||
'items' => $this->domainItems(),
|
||||
],
|
||||
'environment' => [
|
||||
'label' => 'Environment Variables',
|
||||
'items' => $this->environmentItems(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function hash(): string
|
||||
{
|
||||
return self::hashSnapshot($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
*/
|
||||
public static function hashSnapshot(array $snapshot): string
|
||||
{
|
||||
return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function comparableSnapshot(array $snapshot): array
|
||||
{
|
||||
$sections = collect(data_get($snapshot, 'sections', []))
|
||||
->mapWithKeys(function (array $section, string $sectionKey): array {
|
||||
$items = collect(data_get($section, 'items', []))
|
||||
->mapWithKeys(fn (array $item): array => [
|
||||
$item['key'] => [
|
||||
'compare_value' => $item['compare_value'] ?? null,
|
||||
'impact' => $item['impact'] ?? 'redeploy',
|
||||
],
|
||||
])
|
||||
->sortKeys()
|
||||
->all();
|
||||
|
||||
return [$sectionKey => $items];
|
||||
})
|
||||
->sortKeys()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'schema_version' => data_get($snapshot, 'schema_version'),
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function sourceItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('git_repository', 'Repository', $this->application->git_repository, 'build'),
|
||||
$this->item('git_branch', 'Branch', $this->application->git_branch, 'build'),
|
||||
$this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'),
|
||||
$this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'),
|
||||
$this->item('static_image', 'Static image', $this->application->static_image, 'build'),
|
||||
$this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'),
|
||||
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
|
||||
$this->item('install_command', 'Install command', $this->application->install_command, 'build'),
|
||||
$this->item('build_command', 'Build command', $this->application->build_command, 'build'),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)),
|
||||
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
|
||||
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
|
||||
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
|
||||
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)),
|
||||
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)),
|
||||
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
|
||||
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
|
||||
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
|
||||
$this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'),
|
||||
$this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'),
|
||||
$this->item('disable_build_cache', 'Disable build cache', data_get($this->application, 'settings.disable_build_cache'), 'build'),
|
||||
$this->item('is_build_server_enabled', 'Build server', data_get($this->application, 'settings.is_build_server_enabled'), 'build'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function runtimeItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'),
|
||||
$this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'),
|
||||
$this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'),
|
||||
$this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'),
|
||||
$this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'),
|
||||
$this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'),
|
||||
$this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'),
|
||||
$this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'),
|
||||
$this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'),
|
||||
$this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'),
|
||||
$this->item('gpu_count', 'GPU count', data_get($this->application, 'settings.gpu_count'), 'redeploy'),
|
||||
$this->item('gpu_device_ids', 'GPU device IDs', data_get($this->application, 'settings.gpu_device_ids'), 'redeploy'),
|
||||
$this->item('gpu_options', 'GPU options', data_get($this->application, 'settings.gpu_options'), 'redeploy'),
|
||||
...$this->healthCheckItems(),
|
||||
...$this->limitItems(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function domainItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)),
|
||||
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
|
||||
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
|
||||
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
|
||||
$this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'),
|
||||
$this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'),
|
||||
$this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function environmentItems(): array
|
||||
{
|
||||
return $this->application->environment_variables()
|
||||
->get()
|
||||
->sortBy('key', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values()
|
||||
->map(fn (EnvironmentVariable $environmentVariable): array => $this->environmentItem($environmentVariable))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function healthCheckItems(): array
|
||||
{
|
||||
return collect([
|
||||
'health_check_enabled' => 'Health check enabled',
|
||||
'health_check_path' => 'Health check path',
|
||||
'health_check_port' => 'Health check port',
|
||||
'health_check_host' => 'Health check host',
|
||||
'health_check_method' => 'Health check method',
|
||||
'health_check_return_code' => 'Health check return code',
|
||||
'health_check_scheme' => 'Health check scheme',
|
||||
'health_check_response_text' => 'Health check response text',
|
||||
'health_check_interval' => 'Health check interval',
|
||||
'health_check_timeout' => 'Health check timeout',
|
||||
'health_check_retries' => 'Health check retries',
|
||||
'health_check_start_period' => 'Health check start period',
|
||||
'health_check_type' => 'Health check type',
|
||||
'health_check_command' => 'Health check command',
|
||||
])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function limitItems(): array
|
||||
{
|
||||
return collect([
|
||||
'limits_memory' => 'Memory limit',
|
||||
'limits_memory_swap' => 'Memory swap limit',
|
||||
'limits_memory_swappiness' => 'Memory swappiness',
|
||||
'limits_memory_reservation' => 'Memory reservation',
|
||||
'limits_cpus' => 'CPU limit',
|
||||
'limits_cpuset' => 'CPU set',
|
||||
'limits_cpu_shares' => 'CPU shares',
|
||||
'swarm_replicas' => 'Swarm replicas',
|
||||
'swarm_placement_constraints' => 'Swarm placement constraints',
|
||||
])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function environmentItem(EnvironmentVariable $environmentVariable): array
|
||||
{
|
||||
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
|
||||
$compareValue = [
|
||||
'value_hash' => $this->sensitiveHash($environmentVariable->value),
|
||||
'is_multiline' => $environmentVariable->is_multiline,
|
||||
'is_literal' => $environmentVariable->is_literal,
|
||||
'is_buildtime' => $environmentVariable->is_buildtime,
|
||||
'is_runtime' => $environmentVariable->is_runtime,
|
||||
];
|
||||
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array
|
||||
{
|
||||
$normalizedValue = $this->normalizeValue($value);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'impact' => $impact,
|
||||
'sensitive' => $sensitive,
|
||||
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
|
||||
'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
|
||||
];
|
||||
}
|
||||
|
||||
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$flags = collect([
|
||||
$environmentVariable->is_buildtime ? 'build-time' : null,
|
||||
$environmentVariable->is_runtime ? 'runtime' : null,
|
||||
$environmentVariable->is_multiline ? 'multiline' : null,
|
||||
$environmentVariable->is_literal ? 'literal' : null,
|
||||
])->filter()->implode(', ');
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function sensitiveHash(mixed $value): string
|
||||
{
|
||||
return hash_hmac('sha256', json_encode($value, JSON_THROW_ON_ERROR), (string) config('app.key', 'coolify'));
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value) || is_numeric($value) || $value === null || is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return Arr::sortRecursive($value);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function displayValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $this->summarizeText(json_encode($value, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $this->summarizeText((string) $value);
|
||||
}
|
||||
|
||||
private function summarizeText(?string $value): string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
$lines = substr_count($value, "\n") + 1;
|
||||
|
||||
if ($lines > 1) {
|
||||
return str($value)->limit(80)." ({$lines} lines)";
|
||||
}
|
||||
|
||||
return str($value)->limit(120)->value();
|
||||
}
|
||||
}
|
||||
112
app/Services/DeploymentConfiguration/ConfigurationDiff.php
Normal file
112
app/Services/DeploymentConfiguration/ConfigurationDiff.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConfigurationDiff
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $changes = [],
|
||||
protected bool $legacyFallback = false,
|
||||
) {}
|
||||
|
||||
public static function unchanged(): self
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
public static function legacy(bool $changed): self
|
||||
{
|
||||
if (! $changed) {
|
||||
return self::unchanged();
|
||||
}
|
||||
|
||||
return new self([
|
||||
[
|
||||
'key' => 'legacy.configuration',
|
||||
'section' => 'configuration',
|
||||
'section_label' => 'Configuration',
|
||||
'label' => 'Configuration',
|
||||
'type' => 'changed',
|
||||
'impact' => 'build',
|
||||
'sensitive' => false,
|
||||
'old_display_value' => 'Previously deployed configuration',
|
||||
'new_display_value' => 'Current configuration',
|
||||
],
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
*/
|
||||
public static function fromChanges(array $changes): self
|
||||
{
|
||||
return new self(array_values($changes));
|
||||
}
|
||||
|
||||
public function isChanged(): bool
|
||||
{
|
||||
return $this->changes !== [];
|
||||
}
|
||||
|
||||
public function isLegacyFallback(): bool
|
||||
{
|
||||
return $this->legacyFallback;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->changes);
|
||||
}
|
||||
|
||||
public function requiresBuild(): bool
|
||||
{
|
||||
return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build');
|
||||
}
|
||||
|
||||
public function requiresRedeploy(): bool
|
||||
{
|
||||
return $this->isChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function changes(): array
|
||||
{
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function groupedChanges(): array
|
||||
{
|
||||
return collect($this->changes)
|
||||
->groupBy('section')
|
||||
->map(fn (Collection $changes): array => [
|
||||
'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()),
|
||||
'changes' => $changes->values()->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'changed' => $this->isChanged(),
|
||||
'count' => $this->count(),
|
||||
'requires_build' => $this->requiresBuild(),
|
||||
'requires_redeploy' => $this->requiresRedeploy(),
|
||||
'legacy_fallback' => $this->isLegacyFallback(),
|
||||
'changes' => $this->changes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Services/DeploymentConfiguration/ConfigurationDiffer.php
Normal file
69
app/Services/DeploymentConfiguration/ConfigurationDiffer.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
class ConfigurationDiffer
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $previousSnapshot
|
||||
* @param array<string, mixed> $currentSnapshot
|
||||
*/
|
||||
public function diff(array $previousSnapshot, array $currentSnapshot): ConfigurationDiff
|
||||
{
|
||||
$previousItems = $this->flattenItems($previousSnapshot);
|
||||
$currentItems = $this->flattenItems($currentSnapshot);
|
||||
$keys = collect(array_keys($previousItems))->merge(array_keys($currentItems))->unique()->sort();
|
||||
$changes = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$previous = $previousItems[$key] ?? null;
|
||||
$current = $currentItems[$key] ?? null;
|
||||
|
||||
if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $current ?? $previous;
|
||||
$sensitive = (bool) data_get($item, 'sensitive', false);
|
||||
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
|
||||
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
|
||||
|
||||
$changes[] = [
|
||||
'key' => $key,
|
||||
'section' => data_get($item, 'section'),
|
||||
'section_label' => data_get($item, 'section_label'),
|
||||
'label' => data_get($item, 'label'),
|
||||
'type' => $type,
|
||||
'impact' => data_get($item, 'impact', 'redeploy'),
|
||||
'sensitive' => $sensitive,
|
||||
'display_summary' => $displaySummary,
|
||||
'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'),
|
||||
'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'),
|
||||
];
|
||||
}
|
||||
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function flattenItems(array $snapshot): array
|
||||
{
|
||||
return collect(data_get($snapshot, 'sections', []))
|
||||
->flatMap(function (array $section, string $sectionKey): array {
|
||||
return collect(data_get($section, 'items', []))
|
||||
->mapWithKeys(function (array $item) use ($section, $sectionKey): array {
|
||||
$key = $sectionKey.'.'.$item['key'];
|
||||
|
||||
return [$key => array_merge($item, [
|
||||
'section' => $sectionKey,
|
||||
'section_label' => data_get($section, 'label', str($sectionKey)->headline()->value()),
|
||||
])];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@
|
|||
'@yearly' => '0 0 1 1 *',
|
||||
];
|
||||
const RESTART_MODE = 'unless-stopped';
|
||||
const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
|
||||
const MIN_STOP_GRACE_PERIOD_SECONDS = 1;
|
||||
const MAX_STOP_GRACE_PERIOD_SECONDS = 3600;
|
||||
|
||||
const DATABASE_DOCKER_IMAGES = [
|
||||
'bitnami/mariadb',
|
||||
|
|
|
|||
12
composer.lock
generated
12
composer.lock
generated
|
|
@ -5229,16 +5229,16 @@
|
|||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "3.0.51",
|
||||
"version": "3.0.52",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "d59c94077f9c9915abb51ddb52ce85188ece1748"
|
||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748",
|
||||
"reference": "d59c94077f9c9915abb51ddb52ce85188ece1748",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
|
||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -5319,7 +5319,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.51"
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -5335,7 +5335,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T01:33:53+00:00"
|
||||
"time": "2026-04-27T07:02:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.1.0',
|
||||
'helper_version' => '1.0.13',
|
||||
'realtime_version' => '1.0.14',
|
||||
'helper_version' => '1.0.14',
|
||||
'realtime_version' => '1.0.15',
|
||||
'railpack_version' => '0.23.0',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_settings', function (Blueprint $table) {
|
||||
$table->integer('stop_grace_period')
|
||||
->nullable()
|
||||
->after('use_build_secrets')
|
||||
->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('application_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('stop_grace_period');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->string('configuration_hash')->nullable()->after('docker_registry_image_tag');
|
||||
$table->json('configuration_snapshot')->nullable()->after('configuration_hash');
|
||||
$table->json('configuration_diff')->nullable()->after('configuration_snapshot');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'configuration_hash',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at');
|
||||
$table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->dropIndex('personal_access_tokens_expiration_warning_index');
|
||||
$table->dropColumn('api_token_expiration_warning_sent_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
513
database/seeders/DevelopmentRailpackExamplesSeeder.php
Normal file
513
database/seeders/DevelopmentRailpackExamplesSeeder.php
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Seeder;
|
||||
use RuntimeException;
|
||||
|
||||
class DevelopmentRailpackExamplesSeeder extends Seeder
|
||||
{
|
||||
public const PROJECT_UUID = 'railpack-examples';
|
||||
|
||||
public const ENVIRONMENT_UUID = 'railpack-examples-production';
|
||||
|
||||
public const GIT_REPOSITORY = 'coollabsio/coolify-examples';
|
||||
|
||||
public const GIT_BRANCH = 'next';
|
||||
|
||||
public const REPOSITORY_PROJECT_ID = 603035348;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
if (! $this->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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.23.0
|
||||
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
|
||||
ARG MISE_VERSION=2026.3.17
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ ARG CLOUDFLARED_VERSION
|
|||
WORKDIR /terminal
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache openssh-client make g++ python3 curl
|
||||
COPY docker/coolify-realtime/package.json ./
|
||||
RUN npm i
|
||||
COPY docker/coolify-realtime/package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm rebuild node-pty --update-binary
|
||||
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
|
||||
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
|
||||
|
|
|
|||
283
docker/coolify-realtime/package-lock.json
generated
283
docker/coolify-realtime/package-lock.json
generated
|
|
@ -7,7 +7,6 @@
|
|||
"dependencies": {
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"axios": "1.15.0",
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
|
|
@ -29,48 +28,6 @@
|
|||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
|
|
@ -84,15 +41,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
|
|
@ -105,228 +53,6 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
|
|
@ -343,15 +69,6 @@
|
|||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cookie": "1.1.1",
|
||||
"axios": "1.15.0",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import pty from 'node-pty';
|
||||
import axios from 'axios';
|
||||
import cookie from 'cookie';
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
|
|
@ -12,9 +11,60 @@ import {
|
|||
isAuthorizedTargetHost,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
async function postToCoolify(path, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({
|
||||
hostname: 'coolify',
|
||||
port: 8080,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers,
|
||||
}, (response) => {
|
||||
let responseText = '';
|
||||
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
responseText += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
data: parseResponseData(response.headers['content-type'], responseText),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseResponseData(contentType = '', responseText = '') {
|
||||
if (responseText === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
return JSON.parse(responseText);
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function createHttpError(response) {
|
||||
const error = new Error(`Request failed with status code ${response.status}`);
|
||||
error.response = response;
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
const userSessions = new Map();
|
||||
const terminalDebugEnabled = ['local', 'development'].includes(
|
||||
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
|
||||
const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
|
||||
String(process.env.TERMINAL_DEBUG || '').toLowerCase()
|
||||
);
|
||||
|
||||
function logTerminal(level, message, context = {}) {
|
||||
|
|
@ -74,11 +124,9 @@ const verifyClient = async (info, callback) => {
|
|||
|
||||
try {
|
||||
// Authenticate with Laravel backend
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
const response = await postToCoolify('/terminal/auth', {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
|
|
@ -161,12 +209,15 @@ wss.on('connection', async (ws, req) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
|
||||
headers: {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
},
|
||||
const response = await postToCoolify('/terminal/auth/ips', {
|
||||
'Cookie': `${sessionCookieName}=${laravelSession}`,
|
||||
'X-XSRF-TOKEN': xsrfToken
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw createHttpError(response);
|
||||
}
|
||||
|
||||
userSession.authorizedIPs = response.data.ipAddresses || [];
|
||||
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
|
||||
...connectionContext,
|
||||
|
|
|
|||
189
openapi.json
189
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."
|
||||
},
|
||||
|
|
@ -2092,173 +2092,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/dockercompose": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Create (Docker Compose)",
|
||||
"description": "Deprecated: Use POST \/api\/v1\/services instead.",
|
||||
"operationId": "create-dockercompose-application",
|
||||
"requestBody": {
|
||||
"description": "Application object that needs to be created.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"project_uuid",
|
||||
"server_uuid",
|
||||
"environment_name",
|
||||
"environment_uuid",
|
||||
"docker_compose_raw"
|
||||
],
|
||||
"properties": {
|
||||
"project_uuid": {
|
||||
"type": "string",
|
||||
"description": "The project UUID."
|
||||
},
|
||||
"server_uuid": {
|
||||
"type": "string",
|
||||
"description": "The server UUID."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"docker_compose_raw": {
|
||||
"type": "string",
|
||||
"description": "The Docker Compose raw content."
|
||||
},
|
||||
"destination_uuid": {
|
||||
"type": "string",
|
||||
"description": "The destination UUID if the server has more than one destinations."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The application name."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The application description."
|
||||
},
|
||||
"instant_deploy": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the application should be deployed instantly."
|
||||
},
|
||||
"use_build_server": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Use build server."
|
||||
},
|
||||
"connect_to_docker_network": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to connect the service to the predefined Docker network."
|
||||
},
|
||||
"force_domain_override": {
|
||||
"type": "boolean",
|
||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
||||
},
|
||||
"is_container_label_escape_enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Application created successfully.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"409": {
|
||||
"description": "Domain conflicts detected.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Domain conflicts detected. Use force_domain_override=true to proceed."
|
||||
},
|
||||
"warning": {
|
||||
"type": "string",
|
||||
"example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"example": "example.com"
|
||||
},
|
||||
"resource_name": {
|
||||
"type": "string",
|
||||
"example": "My Application"
|
||||
},
|
||||
"resource_uuid": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"example": "abc123-def456"
|
||||
},
|
||||
"resource_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"application",
|
||||
"service",
|
||||
"instance"
|
||||
],
|
||||
"example": "application"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Domain example.com is already in use by application 'My Application'"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": true,
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -2457,6 +2290,7 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"nixpacks",
|
||||
"railpack",
|
||||
"static",
|
||||
"dockerfile",
|
||||
"dockercompose"
|
||||
|
|
@ -12607,6 +12441,7 @@
|
|||
"description": "Build pack.",
|
||||
"enum": [
|
||||
"nixpacks",
|
||||
"railpack",
|
||||
"static",
|
||||
"dockerfile",
|
||||
"dockercompose"
|
||||
|
|
@ -12956,6 +12791,18 @@
|
|||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_hash": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_snapshot": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_diff": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"force_rebuild": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
109
openapi.yaml
109
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
|
||||
|
|
@ -1337,95 +1337,6 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/applications/dockercompose:
|
||||
post:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Create (Docker Compose)'
|
||||
description: 'Deprecated: Use POST /api/v1/services instead.'
|
||||
operationId: create-dockercompose-application
|
||||
requestBody:
|
||||
description: 'Application object that needs to be created.'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- project_uuid
|
||||
- server_uuid
|
||||
- environment_name
|
||||
- environment_uuid
|
||||
- docker_compose_raw
|
||||
properties:
|
||||
project_uuid:
|
||||
type: string
|
||||
description: 'The project UUID.'
|
||||
server_uuid:
|
||||
type: string
|
||||
description: 'The server UUID.'
|
||||
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.'
|
||||
docker_compose_raw:
|
||||
type: string
|
||||
description: 'The Docker Compose raw content.'
|
||||
destination_uuid:
|
||||
type: string
|
||||
description: 'The destination UUID if the server has more than one destinations.'
|
||||
name:
|
||||
type: string
|
||||
description: 'The application name.'
|
||||
description:
|
||||
type: string
|
||||
description: 'The application description.'
|
||||
instant_deploy:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the application should be deployed instantly.'
|
||||
use_build_server:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: 'Use build server.'
|
||||
connect_to_docker_network:
|
||||
type: boolean
|
||||
description: 'The flag to connect the service to the predefined Docker network.'
|
||||
force_domain_override:
|
||||
type: boolean
|
||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
||||
is_container_label_escape_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'Application created successfully.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
uuid: { type: string }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'409':
|
||||
description: 'Domain conflicts detected.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
|
||||
warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
|
||||
conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
|
||||
type: object
|
||||
deprecated: true
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}':
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -1568,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
|
||||
|
|
@ -7977,6 +7888,7 @@ components:
|
|||
description: 'Build pack.'
|
||||
enum:
|
||||
- nixpacks
|
||||
- railpack
|
||||
- static
|
||||
- dockerfile
|
||||
- dockercompose
|
||||
|
|
@ -8246,6 +8158,15 @@ components:
|
|||
docker_registry_image_tag:
|
||||
type: string
|
||||
nullable: true
|
||||
configuration_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
configuration_snapshot:
|
||||
type: object
|
||||
nullable: true
|
||||
configuration_diff:
|
||||
type: object
|
||||
nullable: true
|
||||
force_rebuild:
|
||||
type: boolean
|
||||
commit:
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"version": "1.0.13"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.14"
|
||||
"version": "1.0.15"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.21"
|
||||
|
|
|
|||
306
package-lock.json
generated
306
package-lock.json
generated
|
|
@ -16,7 +16,6 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"axios": "1.15.0",
|
||||
"laravel-echo": "2.2.7",
|
||||
"laravel-vite-plugin": "2.0.1",
|
||||
"postcss": "8.5.6",
|
||||
|
|
@ -1466,39 +1465,6 @@
|
|||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
|
|
@ -1518,19 +1484,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -1567,16 +1520,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
|
|
@ -1596,21 +1539,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
|
|
@ -1664,55 +1592,6 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
|
|
@ -1780,44 +1659,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -1833,68 +1674,6 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
|
|
@ -1902,48 +1681,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
|
||||
|
|
@ -2313,39 +2050,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-svg-data-uri": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||
|
|
@ -2500,16 +2204,6 @@
|
|||
"react": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"axios": "1.15.0",
|
||||
"laravel-echo": "2.2.7",
|
||||
"laravel-vite-plugin": "2.0.1",
|
||||
"postcss": "8.5.6",
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ @utility menu-item {
|
|||
@apply flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0;
|
||||
}
|
||||
@utility menu-item-icon {
|
||||
@apply flex-shrink-0 w-6 h-6 dark:hover:text-white;
|
||||
@apply shrink-0 size-4 dark:hover:text-white;
|
||||
}
|
||||
|
||||
@utility menu-item-label {
|
||||
|
|
@ -201,7 +201,7 @@ @utility sub-menu-item {
|
|||
}
|
||||
|
||||
@utility sub-menu-item-icon {
|
||||
@apply flex-shrink-0 w-4 h-4 dark:hover:text-white;
|
||||
@apply shrink-0 size-4 dark:hover:text-white;
|
||||
}
|
||||
|
||||
@utility heading-item-active {
|
||||
|
|
@ -347,8 +347,12 @@ @utility log-info {
|
|||
@media (min-width: 1024px) {
|
||||
.sidebar-collapsed .menu-item {
|
||||
justify-content: center;
|
||||
width: var(--button-h, 2rem);
|
||||
height: var(--button-h, 2rem);
|
||||
min-height: var(--button-h, 2rem);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
gap: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
@props([
|
||||
'diff' => null,
|
||||
'compact' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$changes = data_get($diff, 'changes', []);
|
||||
$count = data_get($diff, 'count', count($changes));
|
||||
$requiresBuild = data_get($diff, 'requires_build', false);
|
||||
@endphp
|
||||
|
||||
@if ($count > 0)
|
||||
<div @class([
|
||||
'text-xs' => $compact,
|
||||
'text-sm' => ! $compact,
|
||||
])>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2 font-semibold text-black dark:text-white">
|
||||
<span>{{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }}</span>
|
||||
<span @class([
|
||||
'rounded-sm px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase leading-none',
|
||||
'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild,
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
|
||||
])>
|
||||
{{ $requiresBuild ? 'Rebuild' : 'Redeploy' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@unless ($compact)
|
||||
<div class="space-y-2">
|
||||
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
|
||||
<div>
|
||||
<div class="mb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400">
|
||||
{{ $sectionLabel }}
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-sm border border-neutral-300 dark:border-coolgray-200">
|
||||
<div class="min-w-[44rem]">
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
|
||||
<div>Field</div>
|
||||
<div>Type</div>
|
||||
<div>From</div>
|
||||
<div></div>
|
||||
<div>To</div>
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="truncate font-medium text-black dark:text-white" title="{{ data_get($change, 'label') }}">
|
||||
{{ data_get($change, 'label') }}
|
||||
</div>
|
||||
<div class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ data_get($change, 'type') }}
|
||||
</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<nav class="flex flex-col flex-1 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
|
||||
:class="collapsed ? 'lg:px-1 px-2 sidebar-collapsed' : 'px-2'"
|
||||
:class="collapsed ? 'px-2 lg:px-[0.7rem] sidebar-collapsed' : 'px-2 lg:px-[0.7rem]'"
|
||||
@mouseover="
|
||||
if (!collapsed) return;
|
||||
const el = $event.target.closest('.menu-item');
|
||||
|
|
@ -92,8 +92,8 @@
|
|||
}
|
||||
}
|
||||
}">
|
||||
<div class="flex pt-4 pb-4 pl-2 items-start gap-2"
|
||||
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:gap-3 lg:pt-8' : 'lg:pt-6'">
|
||||
<div class="flex pt-4 pb-4 pl-2 items-start gap-2 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none"
|
||||
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:gap-3 lg:pt-7' : 'lg:pt-6'">
|
||||
<div class="flex flex-col w-full" :class="collapsed && 'lg:hidden'">
|
||||
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-tight dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
|
||||
<x-version />
|
||||
|
|
@ -124,7 +124,7 @@ class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400
|
|||
<livewire:settings-dropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pt-2 pb-7" :class="collapsed && 'lg:px-0 lg:pt-0 lg:pb-4 lg:flex lg:justify-center'">
|
||||
<div class="px-2 pt-2 pb-7 overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none" :class="collapsed && 'lg:px-0 lg:pt-0 lg:pb-4 lg:min-h-8 lg:flex lg:justify-center'">
|
||||
<livewire:switch-team />
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col flex-1 gap-y-7">
|
||||
|
|
@ -425,7 +425,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
|
|||
<path fill="currentColor"
|
||||
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2a9.985 9.985 0 0 1 8 4h-2.71a8 8 0 1 0 .001 12h2.71A9.985 9.985 0 0 1 12 22m7-6v-3h-8v-2h8V8l5 4z" />
|
||||
</svg>
|
||||
<span :class="collapsed && 'lg:hidden'">Logout</span>
|
||||
<span class="text-left menu-item-label" :class="collapsed && 'lg:hidden'">Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
<!-- Global search component - included once to prevent keyboard shortcut duplication -->
|
||||
<livewire:global-search />
|
||||
@auth
|
||||
<livewire:deployments-indicator />
|
||||
<div x-data="{
|
||||
open: false,
|
||||
collapsed: false,
|
||||
|
|
@ -26,6 +25,7 @@
|
|||
}
|
||||
}" x-cloak class="mx-auto dark:text-inherit text-black"
|
||||
:class="pageWidth === 'full' ? '' : 'max-w-7xl'">
|
||||
<livewire:deployments-indicator />
|
||||
<div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div>
|
||||
<div class="fixed inset-y-0 right-0 h-full flex">
|
||||
|
|
@ -79,10 +79,8 @@ class="text-xl font-bold tracking-wide dark:text-white hover:opacity-80 transiti
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<main class="transition-[padding] duration-200" :class="collapsed ? 'lg:pl-16' : 'lg:pl-56'">
|
||||
<div class="p-4 sm:px-6 lg:px-8 lg:py-6">
|
||||
<main class="transition-[padding] duration-200 p-6" :class="collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]'">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@endauth
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<div wire:poll.3000ms x-data="{
|
||||
expanded: @entangle('expanded'),
|
||||
reduceOpacity: @js($this->shouldReduceOpacity)
|
||||
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
|
||||
}" class="fixed bottom-0 left-0 z-60 mb-4 ml-4 transition-[left] duration-200"
|
||||
:class="collapsed ? 'lg:left-16' : 'lg:left-56'">
|
||||
@if ($this->deploymentCount > 0)
|
||||
<div class="relative transition-opacity duration-200"
|
||||
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
|
||||
|
|
|
|||
|
|
@ -1137,4 +1137,4 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -86,7 +86,21 @@
|
|||
helper="Readonly labels are disabled. You need to set the labels in the labels section." disabled
|
||||
instantSave id="isStripprefixEnabled" label="Strip Prefixes" canGate="update" :canResource="$application" />
|
||||
@endif
|
||||
|
||||
<h3 class="pt-4">Operations</h3>
|
||||
<form class="flex items-end gap-2" wire:submit.prevent='saveStopGracePeriod'>
|
||||
<x-forms.input
|
||||
type="number"
|
||||
id="stopGracePeriod"
|
||||
label="Stop Grace Period (seconds)"
|
||||
placeholder="{{ DEFAULT_STOP_GRACE_PERIOD_SECONDS }}"
|
||||
helper="How long to wait for graceful shutdown during rolling updates, manual stops, and restarts. Applies to all containers for this application. Default: {{ DEFAULT_STOP_GRACE_PERIOD_SECONDS }} seconds. Range: {{ MIN_STOP_GRACE_PERIOD_SECONDS }}-{{ MAX_STOP_GRACE_PERIOD_SECONDS }} seconds (1 hour)."
|
||||
min="{{ MIN_STOP_GRACE_PERIOD_SECONDS }}"
|
||||
max="{{ MAX_STOP_GRACE_PERIOD_SECONDS }}"
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
/>
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</form>
|
||||
<h3 class="pt-4">Logs</h3>
|
||||
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
|
||||
instantSave id="isLogDrainEnabled" label="Drain Logs" canGate="update" :canResource="$application" />
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
<x-forms.select x-bind:disabled="shouldDisable()" wire:model.live="buildPack" label="Build Pack"
|
||||
required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
|
|
@ -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')
|
||||
<div class="flex flex-col gap-2 xl:flex-row">
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="installCommand" label="Install Command" x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="buildCommand" label="Build Command" x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="startCommand" label="Start Command" x-bind:disabled="!canUpdate" />
|
||||
</div>
|
||||
<div class="pt-1 text-xs">Nixpacks will detect the required configuration
|
||||
automatically.
|
||||
@if ($buildPack === 'nixpacks')
|
||||
<div class="pt-1 text-xs">
|
||||
|
||||
<span class="font-medium">Nixpacks</span>
|
||||
will detect the required configuration automatically.
|
||||
<a class="underline" href="https://coolify.io/docs/applications/">Framework
|
||||
Specific Docs</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<x-slot:title>
|
||||
Projects | Coolify
|
||||
</x-slot>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<h1>Projects</h1>
|
||||
@can('createAnyResource')
|
||||
<x-modal-input buttonTitle="+ Add" title="New Project">
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
|
|||
<x-forms.input id="branch" required label="Branch" />
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
</x-forms.select>
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
@endif
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-activ
|
|||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
|
||||
:class="{ 'z-40': modalOpen }" class="relative">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,77 @@
|
|||
<div>
|
||||
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
</x-slot:title>
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>
|
||||
</x-slot:icon>
|
||||
<x-slot:description>
|
||||
<span>Please redeploy to apply the new configuration.</span>
|
||||
</x-slot:description>
|
||||
<x-slot:button-text @click="disableSponsorship()">
|
||||
Disable This Popup
|
||||
</x-slot:button-text>
|
||||
</x-popup-small>
|
||||
<div x-data="{ configurationDiffModalOpen: false }">
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
</x-slot:title>
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>
|
||||
</x-slot:icon>
|
||||
<x-slot:description>
|
||||
<span>
|
||||
@if (data_get($configurationDiff, 'count'))
|
||||
{{ data_get($configurationDiff, 'count') }} unapplied configuration
|
||||
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} detected.
|
||||
@if (data_get($configurationDiff, 'requires_build'))
|
||||
A rebuild is required.
|
||||
@else
|
||||
Please redeploy to apply the new configuration.
|
||||
@endif
|
||||
<button type="button" class="ml-1 font-semibold underline text-coollabs dark:text-warning"
|
||||
x-on:click="$wire.refreshConfigurationChanges().then(() => configurationDiffModalOpen = true)"
|
||||
wire:loading.attr="disabled" wire:target="refreshConfigurationChanges">
|
||||
View changes
|
||||
</button>
|
||||
@else
|
||||
Please redeploy to apply the new configuration.
|
||||
@endif
|
||||
</span>
|
||||
</x-slot:description>
|
||||
</x-popup-small>
|
||||
|
||||
@if (data_get($configurationDiff, 'count'))
|
||||
<template x-teleport="body">
|
||||
<div x-show="configurationDiffModalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4"
|
||||
@keydown.escape.window="configurationDiffModalOpen = false">
|
||||
<div x-show="configurationDiffModalOpen" x-transition.opacity
|
||||
class="absolute inset-0 h-full w-full bg-black/20 backdrop-blur-xs"
|
||||
@click="configurationDiffModalOpen = false"></div>
|
||||
<div x-show="configurationDiffModalOpen" x-trap.inert.noscroll="configurationDiffModalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative flex max-h-[85vh] w-full flex-col rounded-sm border border-neutral-200 bg-white shadow-lg dark:border-coolgray-300 dark:bg-base lg:max-w-4xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-5 dark:border-coolgray-300">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-black dark:text-white">Configuration changes</h3>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
These changes are not applied to the latest deployment yet.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="configurationDiffModalOpen = false"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6">
|
||||
<x-deployment.configuration-diff :diff="$configurationDiff" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
|
|
@ -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)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
|
|
@ -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)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
}">
|
||||
<button @click="openTeamMenu($event)" type="button"
|
||||
title="Team: {{ $currentTeam->name }}"
|
||||
class="flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-md bg-coollabs hover:opacity-80 transition-opacity text-white cursor-pointer">
|
||||
class="flex items-center justify-center w-8 h-8 p-0 text-sm font-semibold text-coollabs dark:text-warning bg-neutral-100 dark:bg-coolgray-200 hover:bg-neutral-200 dark:hover:bg-coolgray-300 rounded-sm cursor-pointer transition-colors">
|
||||
{{ $teamInitial }}
|
||||
</button>
|
||||
<div x-show="teamOpen"
|
||||
|
|
|
|||
|
|
@ -108,11 +108,6 @@
|
|||
Route::post('/applications/dockerfile', [ApplicationsController::class, 'create_dockerfile_application'])->middleware(['api.ability:write']);
|
||||
Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']);
|
||||
|
||||
/**
|
||||
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services.
|
||||
*/
|
||||
Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
|
|
|||
322
scripts/railpack-smoke.sh
Executable file
322
scripts/railpack-smoke.sh
Executable file
|
|
@ -0,0 +1,322 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Railpack end-to-end deploy smoke test against the local dev stack.
|
||||
#
|
||||
# Walks a curated set of railpack-* example apps from
|
||||
# DevelopmentRailpackExamplesSeeder, triggers a deploy via the Coolify API,
|
||||
# waits for the deployment queue to finish, then exec()s into the resulting
|
||||
# container and checks that COOLIFY_*, SOURCE_COMMIT, and any RAILPACK_*
|
||||
# build inputs landed correctly. Optionally curls the FQDN.
|
||||
#
|
||||
# Requires:
|
||||
# - Dev stack running: spin up (or docker compose -f docker-compose.dev.yml up -d)
|
||||
# - Seeder run: php artisan db:seed --class=DevelopmentRailpackExamplesSeeder
|
||||
# - Personal token: PersonalAccessTokenSeeder run (creates Bearer 'root')
|
||||
# - jq, curl available on host
|
||||
#
|
||||
# Usage:
|
||||
# scripts/railpack-smoke.sh # default subset
|
||||
# scripts/railpack-smoke.sh --app railpack-laravel # single app
|
||||
# scripts/railpack-smoke.sh --all # every seeded railpack-* app
|
||||
# scripts/railpack-smoke.sh --timeout 900 # build wait per app, seconds
|
||||
# scripts/railpack-smoke.sh --no-curl # skip FQDN curl
|
||||
# scripts/railpack-smoke.sh --extra-env KEY=VALUE # build+runtime env (alias of --both-env)
|
||||
# scripts/railpack-smoke.sh --build-env KEY=VALUE # buildtime-only env (must reach build, NOT runtime)
|
||||
# scripts/railpack-smoke.sh --runtime-env KEY=VALUE # runtime-only env (must reach runtime, NOT build)
|
||||
# scripts/railpack-smoke.sh --both-env KEY=VALUE # buildtime+runtime env
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE="${COOLIFY_API_BASE:-http://localhost:8000/api/v1}"
|
||||
TOKEN="${COOLIFY_API_TOKEN:-root}"
|
||||
TIMEOUT="${SMOKE_TIMEOUT:-600}"
|
||||
DO_CURL=1
|
||||
BUILD_ENVS=()
|
||||
RUNTIME_ENVS=()
|
||||
BOTH_ENVS=()
|
||||
APPS=()
|
||||
|
||||
DEFAULT_APPS=(
|
||||
railpack-expressjs
|
||||
railpack-nestjs
|
||||
railpack-nextjs-ssr
|
||||
railpack-vite-static
|
||||
railpack-astro-static
|
||||
railpack-python-flask
|
||||
railpack-go-gin
|
||||
railpack-rust
|
||||
railpack-symfony
|
||||
railpack-bun
|
||||
)
|
||||
|
||||
while (( $# > 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
|
||||
26
templates/compose/gitea-runner.yaml
Normal file
26
templates/compose/gitea-runner.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# documentation: https://github.com/go-gitea/gitea
|
||||
# category: devtools
|
||||
# slogan: Gitea Actions runner for docker
|
||||
# tags: gitea,actions,runner,docker
|
||||
# logo: svgs/gitea.svg
|
||||
|
||||
services:
|
||||
runner:
|
||||
image: 'docker.io/gitea/runner:1.0.0'
|
||||
environment:
|
||||
- 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}'
|
||||
- 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}'
|
||||
- 'GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-gitea-runner}'
|
||||
- 'GITEA_RUNNER_LABELS=${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:22}'
|
||||
- 'GITEA_TOKEN=${GITEA_TOKEN}'
|
||||
working_dir: /data
|
||||
volumes:
|
||||
- 'runner-data:/data'
|
||||
- '/var/run/docker.sock:/var/run/docker.sock'
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "ps aux | grep '[R]unner' > /dev/null || exit 1"
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 15
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
# ignore: true
|
||||
# documentation: https://litequeen.com/
|
||||
# slogan: Lite Queen is an open-source SQLite database management software that runs on your server.
|
||||
# category: database
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
344
tests/Feature/Api/RailpackApiTest.php
Normal file
344
tests/Feature/Api/RailpackApiTest.php
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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();
|
||||
expect($app->dockerfile_location)->toBeNull();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\User;
|
||||
use App\Notifications\ApiTokenExpiringNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Notifications\Dispatcher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
|
@ -29,11 +30,12 @@
|
|||
Notification::fake();
|
||||
});
|
||||
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
|
||||
{
|
||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||
$token = $plain->accessToken;
|
||||
$token->team_id = $team->id;
|
||||
$token->api_token_expiration_warning_sent_at = $warningSentAt;
|
||||
$token->save();
|
||||
|
||||
return $token->fresh();
|
||||
|
|
@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
|||
|
||||
describe('ApiTokenExpirationWarningJob', function () {
|
||||
test('notifies team when token expires within 24h', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('rate limiter prevents duplicate warnings on repeat runs', function () {
|
||||
test('does not mark token as warned when notification fails', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||
$dispatcher->shouldReceive('send')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Notification failed'));
|
||||
|
||||
$this->app->instance(Dispatcher::class, $dispatcher);
|
||||
|
||||
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
|
||||
->toThrow(RuntimeException::class, 'Notification failed');
|
||||
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
|
@ -57,6 +75,35 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
|||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('skips tokens that already have an expiration warning marker', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('notifies once for each unmarked expiring token', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
|
||||
});
|
||||
|
||||
test('skips tokens expiring more than 24h out', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
@ -156,6 +240,27 @@
|
|||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('dockerfile location defaults only for dockerfile buildpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$nixpacksApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
$dockerfileApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
|
||||
expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('model does not trigger cleanup when build_pack is not changed', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
|
|
|||
97
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
97
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configurationChangedTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
return ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
}
|
||||
|
||||
it('stores deployment configuration snapshot and clears pending changes', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
expect($deployment->refresh()->configuration_hash)->not->toBeNull()
|
||||
->and($deployment->configuration_snapshot)->toBeArray()
|
||||
->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores a diff between successful deployments', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$firstDeployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($firstDeployment);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$secondDeployment = configurationChangedDeployment($application->refresh());
|
||||
$application->markDeploymentConfigurationApplied($secondDeployment);
|
||||
|
||||
expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
|
||||
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
|
||||
});
|
||||
|
||||
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'preview',
|
||||
'is_preview' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$application->forceFill([
|
||||
'config_hash' => 'legacy-hash',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
$diff = $application->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isLegacyFallback())->toBeTrue()
|
||||
->and($diff->isChanged())->toBeTrue();
|
||||
});
|
||||
|
||||
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$application->isConfigurationChanged(save: true);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
|
||||
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
|
||||
});
|
||||
150
tests/Feature/ApplicationCustomNginxConfigurationApiTest.php
Normal file
150
tests/Feature/ApplicationCustomNginxConfigurationApiTest.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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' => 'custom-nginx-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 customNginxApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function customNginxConfig(): string
|
||||
{
|
||||
return <<<'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGINX;
|
||||
}
|
||||
|
||||
function makeCustomNginxApplication(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' => 'static',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
$configuration = customNginxConfig();
|
||||
$encodedConfiguration = base64_encode($configuration);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => $encodedConfiguration,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
|
||||
$storedConfiguration = DB::table('applications')
|
||||
->where('id', $application->id)
|
||||
->value('custom_nginx_configuration');
|
||||
|
||||
expect($storedConfiguration)->toBe(base64_encode($configuration));
|
||||
|
||||
$this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('custom_nginx_configuration', $configuration);
|
||||
});
|
||||
|
||||
test('rejects custom nginx configuration that is not base64 encoded', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonPath('errors.custom_nginx_configuration', 'The custom_nginx_configuration should be base64 encoded.');
|
||||
});
|
||||
|
||||
test('can clear custom nginx configuration with null', function () {
|
||||
$application = makeCustomNginxApplication([
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/public custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it on create', function () {
|
||||
$configuration = customNginxConfig();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'custom_nginx_configuration' => base64_encode($configuration),
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
});
|
||||
});
|
||||
388
tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
Normal file
388
tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public ?string $writtenDockerfile = null;
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->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();
|
||||
});
|
||||
85
tests/Feature/ApplicationGeneralBuildpackSelectorTest.php
Normal file
85
tests/Feature/ApplicationGeneralBuildpackSelectorTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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([
|
||||
'<option value="nixpacks">Nixpacks</option>',
|
||||
'<option value="railpack">Railpack (Beta)</option>',
|
||||
], false);
|
||||
});
|
||||
|
||||
test('existing application shows railpack beta label in build pack selector', 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 (Beta)');
|
||||
});
|
||||
126
tests/Feature/ApplicationPreviewImageNameTest.php
Normal file
126
tests/Feature/ApplicationPreviewImageNameTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
|
||||
function makePreviewImageNameJob(string $commit, int $pullRequestId = 42, ?string $registryImageName = null, string $deploymentUuid = 'deployment-uuid'): object
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'preview-app';
|
||||
$application->build_pack = 'dockerfile';
|
||||
$application->dockerfile = null;
|
||||
$application->docker_registry_image_name = $registryImageName;
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'commit' => $commit,
|
||||
'deployment_uuid' => $deploymentUuid,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function generatePreviewImageNames(object $job): array
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $reflection->getMethod('generate_image_names');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
$buildImageName = $reflection->getProperty('build_image_name');
|
||||
$buildImageName->setAccessible(true);
|
||||
|
||||
$productionImageName = $reflection->getProperty('production_image_name');
|
||||
$productionImageName->setAccessible(true);
|
||||
|
||||
return [
|
||||
'build' => $buildImageName->getValue($job),
|
||||
'production' => $productionImageName->getValue($job),
|
||||
];
|
||||
}
|
||||
|
||||
it('includes the pull request id and commit in preview image names', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('generates different preview image names for different commits on the same pull request', function () {
|
||||
$firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
$secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production'])
|
||||
->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']);
|
||||
});
|
||||
|
||||
it('uses the deployment uuid for preview image names when commit is HEAD', function () {
|
||||
$firstDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-one',
|
||||
));
|
||||
$secondDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-two',
|
||||
));
|
||||
|
||||
expect($firstDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-one')
|
||||
->and($firstDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-one-build')
|
||||
->and($secondDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-two')
|
||||
->and($secondDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-two-build');
|
||||
});
|
||||
|
||||
it('uses the configured registry image name for commit-specific preview tags', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
registryImageName: 'registry.example.com/team/app',
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('sanitizes and truncates preview image tags to docker tag limits', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: str_repeat('feature/add dockerfile changes/', 10),
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
$productionTag = str($names['production'])->after(':')->toString();
|
||||
$buildTag = str($names['build'])->after(':')->toString();
|
||||
|
||||
expect(strlen($productionTag))->toBeLessThanOrEqual(128)
|
||||
->and(strlen($buildTag))->toBeLessThanOrEqual(128)
|
||||
->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/')
|
||||
->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/');
|
||||
});
|
||||
|
||||
it('keeps non-preview dockerfile image names commit based', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 0,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
150
tests/Feature/ApplicationRailpackTest.php
Normal file
150
tests/Feature/ApplicationRailpackTest.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Application Railpack Support', function () {
|
||||
beforeEach(function () {
|
||||
$team = Team::factory()->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');
|
||||
});
|
||||
});
|
||||
51
tests/Feature/ApplicationSeederTest.php
Normal file
51
tests/Feature/ApplicationSeederTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Database\Seeders\ApplicationSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
|
||||
$this->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);
|
||||
});
|
||||
|
|
@ -24,12 +24,23 @@
|
|||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function applicationSourceValidPrivateKey(): string
|
||||
{
|
||||
return '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => 'test-key-content',
|
||||
'private_key' => applicationSourceValidPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
|
|
@ -56,4 +67,19 @@
|
|||
->assertDontSee('Deploy Key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
|
||||
test('dispatches configuration changed when source settings are saved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->set('gitBranch', 'next')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
tests/Feature/DeploymentsIndicatorLayoutTest.php
Normal file
18
tests/Feature/DeploymentsIndicatorLayoutTest.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
it('positions the deployments indicator from the sidebar collapsed state', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->toContain('transition-[left] duration-200')
|
||||
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
|
||||
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
|
||||
|
||||
expect($layoutView)
|
||||
->toContain('<div x-data="{')
|
||||
->toContain('<livewire:deployments-indicator />');
|
||||
|
||||
expect(strpos($layoutView, '<div x-data="{'))
|
||||
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
test('deprecated docker compose application endpoint is not registered', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(fn ($route) => in_array('POST', $route->methods(), true))
|
||||
->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
|
||||
|
||||
expect($routes)->toBeEmpty();
|
||||
|
||||
$this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
|
||||
});
|
||||
|
||||
test('custom docker compose services endpoint remains registered', function () {
|
||||
$route = collect(Route::getRoutes()->getRoutes())
|
||||
->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
|
||||
|
||||
expect($route)->not->toBeNull()
|
||||
->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
|
||||
});
|
||||
141
tests/Feature/DevelopmentRailpackExamplesSeederTest.php
Normal file
141
tests/Feature/DevelopmentRailpackExamplesSeederTest.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedRailpackExamplePrerequisites(): void
|
||||
{
|
||||
test()->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()));
|
||||
});
|
||||
39
tests/Feature/EnvironmentVariableKeyValidationTest.php
Normal file
39
tests/Feature/EnvironmentVariableKeyValidationTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('rejects environment variable keys Docker cannot represent in the add form', function () {
|
||||
Livewire::test(Add::class)
|
||||
->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';
|
||||
});
|
||||
});
|
||||
158
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
158
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
'fqdn' => 'https://example.com',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markConfigurationCheckerApplicationDeployed(Application $application): void
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
}
|
||||
|
||||
it('does not render the notification for preview deployment toggles', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertDontSee('The latest deployment is not using the current configuration')
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
});
|
||||
|
||||
it('renders the changed configuration labels', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command')
|
||||
->assertSee('A rebuild is required.');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false)
|
||||
->assertDontSee('The latest configuration has not been applied');
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('refreshes stale modal configuration diff before opening changes', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('Build command')
|
||||
->assertDontSee('Start command');
|
||||
|
||||
$application->update([
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Start command')
|
||||
->assertDontSee('Build command');
|
||||
});
|
||||
|
||||
it('does not render environment variable secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markConfigurationCheckerApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('changed')
|
||||
->assertSee('Set')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('old-secret')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
||||
it('renders added environment variables as set without exposing secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('From')
|
||||
->assertSee('Not set')
|
||||
->assertSee('To')
|
||||
->assertSee('Set')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Advanced;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationForAdvancedStopGracePeriodTest(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::create([
|
||||
'name' => 'stop-grace-period-test-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
|
||||
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
});
|
||||
|
||||
it('saves a valid stop grace period', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', '300')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('dispatches configuration changed when advanced settings are saved', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('includeSourceCommitInBuild', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
|
||||
it('clears the stop grace period when submitted empty', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
$application->settings->update(['stop_grace_period' => 300]);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application->fresh()])
|
||||
->set('stopGracePeriod', '')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid stop grace periods', function (string $value, string $rule) {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', $value)
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasErrors(['stopGracePeriod' => [$rule]]);
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
})->with([
|
||||
'below minimum' => ['0', 'min'],
|
||||
'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
|
||||
'malformed integer' => ['10abc', 'integer'],
|
||||
'decimal' => ['1.9', 'integer'],
|
||||
]);
|
||||
|
||||
it('uses one second deployment timeout in local only when stop grace period is unset', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
|
||||
|
||||
$setting->stop_grace_period = 10;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
|
||||
});
|
||||
|
||||
it('uses default deployment timeout outside local when stop grace period is unset', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
|
||||
});
|
||||
121
tests/Feature/Livewire/RailpackLivewireUiTest.php
Normal file
121
tests/Feature/Livewire/RailpackLivewireUiTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
});
|
||||
|
|
@ -299,6 +299,7 @@
|
|||
'inject_build_args_to_dockerfile' => true,
|
||||
'include_source_commit_in_build' => true,
|
||||
'docker_images_to_keep' => 5,
|
||||
'stop_grace_period' => 300,
|
||||
]);
|
||||
|
||||
expect($setting->exists)->toBeTrue();
|
||||
|
|
@ -309,6 +310,7 @@
|
|||
expect($setting->custom_internal_name)->toBe('my-custom-app');
|
||||
expect($setting->is_spa)->toBeTrue();
|
||||
expect($setting->docker_images_to_keep)->toBe(5);
|
||||
expect($setting->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('creates ServerSetting with all fillable attributes', function () {
|
||||
|
|
|
|||
49
tests/Feature/NewApplicationBuildpackDefaultsTest.php
Normal file
49
tests/Feature/NewApplicationBuildpackDefaultsTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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)']);
|
||||
});
|
||||
|
||||
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->set('current_step', 'repository')
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Jobs\SubscriptionInvoiceFailedJob;
|
||||
use App\Jobs\VerifyStripeSubscriptionStatusJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Internal\GeneralNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
|
@ -228,3 +232,65 @@
|
|||
Queue::assertNotPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing subscription Stripe webhooks are ignored', function () {
|
||||
test('does not send internal notifications or queue follow-up jobs', function (array $event) {
|
||||
Queue::fake();
|
||||
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
Notification::assertNotSentTo($rootTeam, GeneralNotification::class);
|
||||
Queue::assertNotPushed(SubscriptionInvoiceFailedJob::class);
|
||||
Queue::assertNotPushed(VerifyStripeSubscriptionStatusJob::class);
|
||||
})->with([
|
||||
'invoice paid' => [[
|
||||
'type' => 'invoice.paid',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_invoice_paid',
|
||||
'amount_paid' => 1000,
|
||||
'subscription' => 'sub_missing_invoice_paid',
|
||||
'lines' => [
|
||||
'data' => [[
|
||||
'plan' => ['id' => 'price_dynamic_monthly'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
]],
|
||||
'invoice payment failed' => [[
|
||||
'type' => 'invoice.payment_failed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_invoice_payment_failed',
|
||||
'id' => 'in_missing_invoice_payment_failed',
|
||||
'payment_intent' => null,
|
||||
],
|
||||
],
|
||||
]],
|
||||
'payment intent payment failed' => [[
|
||||
'type' => 'payment_intent.payment_failed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_payment_intent_failed',
|
||||
],
|
||||
],
|
||||
]],
|
||||
'customer subscription deleted' => [[
|
||||
'type' => 'customer.subscription.deleted',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_missing_subscription_deleted',
|
||||
'id' => 'sub_missing_subscription_deleted',
|
||||
],
|
||||
],
|
||||
]],
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
249
tests/Unit/ApplicationDeploymentRailpackConfigTest.php
Normal file
249
tests/Unit/ApplicationDeploymentRailpackConfigTest.php
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->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').'"');
|
||||
});
|
||||
267
tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php
Normal file
267
tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
|
||||
it('generates escaped railpack env args from resolved values and includes install command', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->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'");
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
it('casts is_static to boolean when true', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = true;
|
||||
$setting->setRawAttributes(['is_static' => true]);
|
||||
|
||||
// Verify it's cast to boolean
|
||||
expect($setting->is_static)->toBeTrue()
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
it('casts is_static to boolean when false', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = false;
|
||||
$setting->setRawAttributes(['is_static' => false]);
|
||||
|
||||
// Verify it's cast to boolean
|
||||
expect($setting->is_static)->toBeFalse()
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
it('casts is_static from string "1" to boolean true', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = '1';
|
||||
$setting->setRawAttributes(['is_static' => '1']);
|
||||
|
||||
// Should cast string to boolean
|
||||
expect($setting->is_static)->toBeTrue()
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
it('casts is_static from string "0" to boolean false', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = '0';
|
||||
$setting->setRawAttributes(['is_static' => '0']);
|
||||
|
||||
// Should cast string to boolean
|
||||
expect($setting->is_static)->toBeFalse()
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
it('casts is_static from integer 1 to boolean true', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = 1;
|
||||
$setting->setRawAttributes(['is_static' => 1]);
|
||||
|
||||
// Should cast integer to boolean
|
||||
expect($setting->is_static)->toBeTrue()
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
it('casts is_static from integer 0 to boolean false', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->is_static = 0;
|
||||
$setting->setRawAttributes(['is_static' => 0]);
|
||||
|
||||
// Should cast integer to boolean
|
||||
expect($setting->is_static)->toBeFalse()
|
||||
|
|
@ -103,3 +103,65 @@
|
|||
->and($casts[$field])->toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('casts stop_grace_period to integer', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$casts = $setting->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('stop_grace_period')
|
||||
->and($casts['stop_grace_period'])->toBe('integer');
|
||||
});
|
||||
|
||||
it('handles null stop_grace_period for default behavior', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = null;
|
||||
|
||||
expect($setting->stop_grace_period)->toBeNull();
|
||||
});
|
||||
|
||||
it('casts stop_grace_period from string to integer', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = '60';
|
||||
|
||||
expect($setting->stop_grace_period)->toBe(60)
|
||||
->and($setting->stop_grace_period)->toBeInt();
|
||||
});
|
||||
|
||||
it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = 0;
|
||||
|
||||
expect($setting->stop_grace_period)->toBe(0)
|
||||
->and($setting->stop_grace_period)->toBeInt();
|
||||
});
|
||||
|
||||
it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = -10;
|
||||
|
||||
expect($setting->stop_grace_period)->toBe(-10)
|
||||
->and($setting->stop_grace_period)->toBeInt();
|
||||
});
|
||||
|
||||
it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = $storedValue;
|
||||
|
||||
expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
|
||||
})->with([
|
||||
'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
|
||||
'custom' => [300, 300],
|
||||
'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
|
||||
]);
|
||||
|
||||
it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
|
||||
$setting = new ApplicationSetting;
|
||||
$setting->stop_grace_period = $storedValue;
|
||||
|
||||
expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
|
||||
})->with([
|
||||
'null' => [null],
|
||||
'zero' => [0],
|
||||
'negative' => [-10],
|
||||
'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function snapshotTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'fqdn' => 'https://example.com',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'npm run start',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
return $deployment->refresh();
|
||||
}
|
||||
|
||||
it('does not report preview deployment toggles as pending production configuration changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('detects build-impacting changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeTrue()
|
||||
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
|
||||
});
|
||||
|
||||
it('detects redeploy-only domain changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->update(['fqdn' => 'https://new.example.com']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeFalse()
|
||||
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
|
||||
});
|
||||
|
||||
it('detects environment variable value changes without exposing secret values', function () {
|
||||
$application = snapshotTestApplication();
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markSnapshotTestApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBe('Changed')
|
||||
->and($change['old_display_value'])->toBe('Set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
|
||||
});
|
||||
|
||||
it('describes added environment variables as set without exposing secret values', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBeNull()
|
||||
->and($change['old_display_value'])->toBe('Not set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue