Merge remote-tracking branch 'origin/next' into audit-policies

This commit is contained in:
Andras Bacsai 2026-05-13 11:15:29 +02:00
commit 6ab88c46b2
66 changed files with 2155 additions and 792 deletions

View file

@ -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);
}

View file

@ -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

View file

@ -983,6 +983,9 @@ private function create_application(Request $request, $type)
],
], 422);
}
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@ -2401,7 +2404,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.',
@ -2419,6 +2422,9 @@ public function update_by_uuid(Request $request)
],
], 422);
}
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {

View file

@ -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]);
}
});
}

View file

@ -537,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()
@ -1154,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);
@ -1176,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}.");
@ -1214,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();
@ -1227,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.");
@ -3766,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]
);
}
@ -4713,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) {

View file

@ -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);
}

View file

@ -345,10 +345,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);
}

View file

@ -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);
}

View file

@ -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 = [];
}
}

View file

@ -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;
@ -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),
);
}
@ -1059,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()
@ -1170,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()

View file

@ -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()

View file

@ -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(

View file

@ -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',
];
}
}

View file

@ -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();
}
}

View 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(),
];
}
}

View 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();
}
}

View file

@ -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
View file

@ -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",

View file

@ -3,9 +3,9 @@
return [
'coolify' => [
'version' => '4.1.0',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.14',
'railpack_version' => '0.22.0',
'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'),

View file

@ -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');
});
}
};

View file

@ -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',
]);
});
}
};

View file

@ -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');
});
}
};

View file

@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
# https://github.com/railwayapp/railpack/releases
ARG RAILPACK_VERSION=0.22.0
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.12
ARG MISE_VERSION=2026.3.17
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z

View file

@ -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

View file

@ -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",

View file

@ -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"
}
}
}

View file

@ -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,

View file

@ -12791,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"
},

View file

@ -8158,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:

View file

@ -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"

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -185,7 +185,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 {
@ -205,7 +205,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 {
@ -351,8 +351,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;
}
}

View file

@ -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

View file

@ -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');
@ -93,7 +93,7 @@
}
}">
<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-8' : 'lg:pt-6'">
: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 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-0 lg:min-h-[4.5rem] 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>

View file

@ -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

View file

@ -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>

View file

@ -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" />

View file

@ -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">

View file

@ -61,14 +61,6 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
<x-forms.input id="publish_directory" required label="Publish Directory" />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -93,14 +93,6 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -52,14 +52,6 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -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>

View file

@ -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>

View file

@ -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"

View 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

View file

@ -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

View file

@ -1634,6 +1634,20 @@
"minversion": "0.0.0",
"port": "2368"
},
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
"runner",
"docker"
],
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
"slogan": "Gitea is a self-hosted, lightweight Git service, offering version control, collaboration, and code hosting.",
@ -2587,22 +2601,6 @@
"minversion": "0.0.0",
"port": "4000"
},
"litequeen": {
"documentation": "https://litequeen.com/?utm_source=coolify.io",
"slogan": "Lite Queen is an open-source SQLite database management software that runs on your server.",
"compose": "c2VydmljZXM6CiAgbGl0ZXF1ZWVuOgogICAgaW1hZ2U6ICdraXZzZWdyb2IvbGl0ZS1xdWVlbjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9MSVRFUVVFRU5fODAwMAogICAgdm9sdW1lczoKICAgICAgLSAnbGl0ZXF1ZWVuLWRhdGE6L2hvbWUvbGl0ZXF1ZWVuL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2RhdGFiYXNlcwogICAgICAgIHRhcmdldDogL3NydgogICAgICAgIGlzX2RpcmVjdG9yeTogdHJ1ZQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvODAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
"tags": [
"sqlite",
"sqlite-database-management",
"self-hosted",
"vps",
"database"
],
"category": "database",
"logo": "svgs/litequeen.svg",
"minversion": "0.0.0",
"port": "8000"
},
"lobe-chat": {
"documentation": "https://github.com/lobehub/lobe-chat?tab=readme-ov-file#b-deploying-with-docker?utm_source=coolify.io",
"slogan": "An open-source, modern-design AI chat framework.",

View file

@ -1634,6 +1634,20 @@
"minversion": "0.0.0",
"port": "2368"
},
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
"runner",
"docker"
],
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
"slogan": "Gitea is a self-hosted, lightweight Git service, offering version control, collaboration, and code hosting.",
@ -2587,22 +2601,6 @@
"minversion": "0.0.0",
"port": "4000"
},
"litequeen": {
"documentation": "https://litequeen.com/?utm_source=coolify.io",
"slogan": "Lite Queen is an open-source SQLite database management software that runs on your server.",
"compose": "c2VydmljZXM6CiAgbGl0ZXF1ZWVuOgogICAgaW1hZ2U6ICdraXZzZWdyb2IvbGl0ZS1xdWVlbjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTElURVFVRUVOXzgwMDAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xpdGVxdWVlbi1kYXRhOi9ob21lL2xpdGVxdWVlbi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9kYXRhYmFzZXMKICAgICAgICB0YXJnZXQ6IC9zcnYKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzgwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK",
"tags": [
"sqlite",
"sqlite-database-management",
"self-hosted",
"vps",
"database"
],
"category": "database",
"logo": "svgs/litequeen.svg",
"minversion": "0.0.0",
"port": "8000"
},
"lobe-chat": {
"documentation": "https://github.com/lobehub/lobe-chat?tab=readme-ov-file#b-deploying-with-docker?utm_source=coolify.io",
"slogan": "An open-source, modern-design AI chat framework.",

View file

@ -85,8 +85,7 @@ function makeRailpackApp(array $overrides = []): Application
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull();
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model
// mutator when set to null, so we cannot assert it becomes null here.
expect($app->dockerfile_location)->toBeNull();
expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
});

View file

@ -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));

View file

@ -240,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]);

View 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();
});

View 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);
});
});

View file

@ -67,7 +67,7 @@
], false);
});
test('existing application shows railpack beta badge in build helper copy', function () {
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,
@ -81,6 +81,5 @@
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack')
->assertSee('Beta');
->assertSee('Railpack (Beta)');
});

View 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');
});

View file

@ -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');
});
});

View 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');
});

View file

@ -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);
});

View file

@ -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 () {

View file

@ -38,14 +38,12 @@
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)'])
->assertSee('Beta');
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
});
test('deploy key repository flow shows railpack beta label in build pack selector', function () {
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)')
->assertSee('Beta');
->assertSee('Railpack (Beta)');
});
});

View file

@ -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],
]);

View file

@ -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');
});

View file

@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
"version": "1.0.14"
"version": "1.0.15"
},
"sentinel": {
"version": "0.0.21"