Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai 2026-07-18 15:57:26 +02:00
commit ae32ab5d7b
139 changed files with 8677 additions and 545 deletions

View file

@ -122,12 +122,7 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide
}
if (! $token) {
logger()->debug('No Vultr token found for team, skipping Vultr deletion', [
'team_id' => $teamId,
'vultr_instance_id' => $vultrInstanceId,
]);
return;
throw new \RuntimeException('No Vultr token found for the server team.');
}
$vultrService = new VultrService($token->token);
@ -143,6 +138,8 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide
'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId,
]);
throw $e;
}
}
@ -165,12 +162,7 @@ private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cl
}
if (! $token) {
logger()->debug('No DigitalOcean token found for team, skipping droplet deletion', [
'team_id' => $teamId,
'digitalocean_droplet_id' => $digitalOceanDropletId,
]);
return;
throw new \RuntimeException('No DigitalOcean token found for the server team.');
}
$digitalOceanService = new DigitalOceanService($token->token);
@ -186,6 +178,8 @@ private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cl
'digitalocean_droplet_id' => $digitalOceanDropletId,
'team_id' => $teamId,
]);
throw $e;
}
}
}

View file

@ -3,6 +3,7 @@
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
@ -12,7 +13,7 @@ class DeployServiceApplication
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
{
$service = $serviceApplication->service;
$composeServiceName = $serviceApplication->name;

View file

@ -3,6 +3,7 @@
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartServiceApplication
@ -11,7 +12,7 @@ class RestartServiceApplication
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;

View file

@ -3,6 +3,7 @@
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
class StopServiceApplication
@ -11,7 +12,7 @@ class StopServiceApplication
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;

View file

@ -1,30 +1,18 @@
<?php
namespace App\Jobs;
namespace App\Actions\Stripe;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Lorisleiva\Actions\Concerns\AsAction;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
class SyncStripeSubscriptions
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use AsAction;
public int $tries = 1;
private const VALID_STRIPE_STATUSES = ['active', 'past_due'];
public int $timeout = 1800; // 30 minutes max
public function __construct(public bool $fix = false)
{
$this->onQueue('high');
}
public function handle(?\Closure $onProgress = null): array
public function handle(bool $fix = false, ?\Closure $onProgress = null): array
{
if (! isCloud() || ! isStripe()) {
return ['error' => 'Not running on Cloud or Stripe not configured'];
@ -34,7 +22,9 @@ public function handle(?\Closure $onProgress = null): array
->where('stripe_invoice_paid', true)
->get();
$stripe = app(StripeClient::class);
$stripe = app()->bound(StripeClient::class)
? app(StripeClient::class)
: new StripeClient(config('subscription.stripe_api_key'));
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@ -43,13 +33,20 @@ public function handle(?\Closure $onProgress = null): array
$staleSubscriptions = $subscriptions->filter(
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
);
$staleSubscriptionCount = $staleSubscriptions->count();
$onProgress?->__invoke('checking', 0, $staleSubscriptionCount);
// For each stale subscription, get the exact Stripe status and check for resubscriptions
$discrepancies = [];
$resubscribed = [];
$errors = [];
$fixedCount = 0;
$manualReviewCount = 0;
foreach ($staleSubscriptions->values() as $index => $subscription) {
$onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount);
foreach ($staleSubscriptions as $subscription) {
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
@ -66,8 +63,18 @@ public function handle(?\Closure $onProgress = null): array
continue;
}
// Check if this user resubscribed under a different customer/subscription
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
continue;
}
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
$validReplacement = Subscription::query()
->where('team_id', $subscription->team_id)
->where('id', '!=', $subscription->id)
->where('stripe_invoice_paid', true)
->whereIn('stripe_subscription_id', $validStripeIds)
->first();
if ($activeSub) {
$resubscribed[] = [
'subscription_id' => $subscription->id,
@ -78,33 +85,69 @@ public function handle(?\Closure $onProgress = null): array
'new_stripe_subscription_id' => $activeSub['subscription_id'],
'new_stripe_customer_id' => $activeSub['customer_id'],
'new_status' => $activeSub['status'],
'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'],
];
continue;
}
$inactiveSubscription = null;
if (! $validReplacement && ! $activeSub) {
$inactiveSubscription = Subscription::query()
->where('team_id', $subscription->team_id)
->where('id', '!=', $subscription->id)
->where('stripe_invoice_paid', false)
->first();
}
$resolution = match (true) {
(bool) $validReplacement => 'delete_stale',
(bool) $activeSub => 'manual_review',
(bool) $inactiveSubscription => 'delete_stale',
default => 'end_subscription',
};
$discrepancies[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'stripe_subscription_id' => $subscription->stripe_subscription_id,
'stripe_status' => $stripeStatus,
'resolution' => $resolution,
];
if ($this->fix) {
$subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
if ($fix) {
$team = $subscription->team;
if ($stripeStatus === 'canceled') {
$subscription->team?->subscriptionEnded();
if ($resolution === 'manual_review') {
$manualReviewCount++;
continue;
}
if ($resolution === 'delete_stale') {
if (! $validReplacement && $inactiveSubscription && $team) {
$team->subscriptionEnded($inactiveSubscription);
}
$subscription->delete();
$fixedCount++;
continue;
}
if ($team) {
$team->subscriptionEnded($subscription);
} else {
$subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
}
$fixedCount++;
}
}
if ($this->fix && count($discrepancies) > 0) {
if ($fix && $fixedCount > 0) {
send_internal_notification(
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
"SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n".
json_encode($discrepancies, JSON_PRETTY_PRINT)
);
}
@ -114,7 +157,9 @@ public function handle(?\Closure $onProgress = null): array
'discrepancies' => $discrepancies,
'resubscribed' => $resubscribed,
'errors' => $errors,
'fixed' => $this->fix,
'fixed' => $fix,
'fixed_count' => $fixedCount,
'manual_review_count' => $manualReviewCount,
];
}
@ -183,13 +228,13 @@ private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure
$validIds = [];
$fetched = 0;
foreach (['active', 'past_due'] as $status) {
foreach (self::VALID_STRIPE_STATUSES as $status) {
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
$validIds[] = $sub->id;
$fetched++;
if ($onProgress) {
$onProgress($fetched);
$onProgress('fetching', $fetched, null);
}
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class CleanupUnverifiedUsers extends Command
{
protected $signature = 'cloud:cleanup-unverified-users
{--yes : Delete eligible users instead of running a dry run}';
protected $description = 'Delete unverified users without Stripe subscriptions or defined resources';
public function handle(): int
{
if (! isCloud()) {
$this->error('This command can only be run on Coolify Cloud.');
return self::FAILURE;
}
$eligibleUsers = $this->eligibleUsers();
$eligibleCount = $eligibleUsers->count();
$this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.');
$shouldDelete = (bool) $this->option('yes');
if (! $shouldDelete) {
$this->warn('Dry run only. Use --yes to delete eligible users.');
}
$deletedCount = 0;
if ($eligibleCount > 0) {
$progressAction = $shouldDelete ? 'Deleting' : 'Checking';
$progressBar = $this->output->createProgressBar($eligibleCount);
$progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%");
$progressBar->start();
foreach ($eligibleUsers->lazyById(100) as $user) {
if ($shouldDelete && $user->delete()) {
$deletedCount++;
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
}
if ($shouldDelete) {
$this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.');
}
return self::SUCCESS;
}
private function eligibleUsers(): Builder
{
return User::query()
->where('id', '!=', 0)
->whereNull('email_verified_at')
->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0))
->whereDoesntHave('teams.subscription')
->whereDoesntHave('teams.servers')
->whereDoesntHave('teams', function (Builder $query) {
$query->whereHas('projects.applications')
->orWhereHas('projects.postgresqls')
->orWhereHas('projects.redis')
->orWhereHas('projects.mongodbs')
->orWhereHas('projects.mysqls')
->orWhereHas('projects.mariadbs')
->orWhereHas('projects.keydbs')
->orWhereHas('projects.dragonflies')
->orWhereHas('projects.clickhouses')
->orWhereHas('projects.services');
});
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
class ExportUsers extends Command
{
protected $signature = 'cloud:export-users';
protected $description = 'Export subscribed and unsubscribed verified Coolify Cloud users to separate CSV files';
public function handle(): int
{
if (! isCloud()) {
$this->error('This command can only be run on Coolify Cloud.');
return self::FAILURE;
}
$backups = Storage::disk('backups');
$backups->delete('cloud-users.csv');
$subscribedPath = $backups->path('cloud-users-subscribed.csv');
$unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv');
$subscribedOutput = fopen($subscribedPath, 'wb');
if ($subscribedOutput === false) {
$this->error("Unable to open {$subscribedPath} for writing.");
return self::FAILURE;
}
$unsubscribedOutput = fopen($unsubscribedPath, 'wb');
if ($unsubscribedOutput === false) {
fclose($subscribedOutput);
$this->error("Unable to open {$unsubscribedPath} for writing.");
return self::FAILURE;
}
$subscribedCount = 0;
$unsubscribedCount = 0;
try {
$header = [
'email',
'first_name',
'last_name',
'lifetime_value_currency',
'lifetime_value_amount',
'utm_campaign',
'utm_source',
'utm_medium',
'utm_content',
'utm_term',
'phone',
];
$this->writeCsvRow($subscribedOutput, $header);
$this->writeCsvRow($unsubscribedOutput, $header);
foreach (User::query()
->select(['id', 'email', 'name'])
->where('id', '!=', 0)
->whereNotNull('email_verified_at')
->withExists([
'teams as is_subscribed' => fn ($query) => $query
->whereRelation('subscription', 'stripe_invoice_paid', true),
])
->lazyById(500) as $user) {
$nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: [];
[$firstName, $lastName] = array_pad($nameParts, 2, '');
$row = [
$user->email,
$firstName,
$lastName,
'',
'',
'',
'',
'',
'',
'',
'',
];
if ($user->is_subscribed) {
$this->writeCsvRow($subscribedOutput, $row);
$subscribedCount++;
} else {
$this->writeCsvRow($unsubscribedOutput, $row);
$unsubscribedCount++;
}
}
} catch (Throwable $exception) {
$this->error("Unable to export users: {$exception->getMessage()}");
return self::FAILURE;
} finally {
fclose($subscribedOutput);
fclose($unsubscribedOutput);
}
$this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}");
$this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}");
return self::SUCCESS;
}
/**
* @param resource $output
* @param array<int, mixed> $fields
*/
private function writeCsvRow($output, array $fields): void
{
if (fputcsv($output, $fields, ',', '"', '') === false) {
throw new RuntimeException('Unable to write the CSV file.');
}
}
}

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands\Cloud;
use App\Jobs\SyncStripeSubscriptionsJob;
use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction;
use Illuminate\Console\Command;
class SyncStripeSubscriptions extends Command
@ -35,14 +35,18 @@ public function handle(): int
$this->newLine();
$job = new SyncStripeSubscriptionsJob($fix);
$fetched = 0;
$result = $job->handle(function (int $count) use (&$fetched): void {
$fetched = $count;
$this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
$progressShown = false;
$result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void {
$progressShown = true;
$message = match ($stage) {
'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}",
default => " Fetching valid subscriptions from Stripe... {$current}",
};
$this->output->write("\r".str_pad($message, 80));
});
if ($fetched > 0) {
$this->output->write("\r".str_repeat(' ', 60)."\r");
if ($progressShown) {
$this->output->write("\r".str_repeat(' ', 80)."\r");
}
if (isset($result['error'])) {
@ -63,13 +67,22 @@ public function handle(): int
$this->line(" Team ID: {$discrepancy['team_id']}");
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
$resolution = match ($discrepancy['resolution']) {
'delete_stale' => 'Delete stale local row',
'manual_review' => 'Manual review required',
default => 'End local subscription',
};
$this->line(" Resolution: {$resolution}");
$this->newLine();
}
if ($fix) {
$this->info('All discrepancies have been fixed.');
$this->info("Automatic corrections applied: {$result['fixed_count']}");
if ($result['manual_review_count'] > 0) {
$this->warn("Skipped for manual review: {$result['manual_review_count']}");
}
} else {
$this->comment('Run with --fix to correct these discrepancies.');
$this->comment('Run with --fix to apply automatic corrections.');
}
} else {
$this->info('No discrepancies found. All subscriptions are in sync.');
@ -84,6 +97,7 @@ public function handle(): int
$this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}");
$this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})");
$this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]");
$this->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No'));
$this->newLine();
}
}

View file

@ -35,6 +35,36 @@ class ApplicationsController extends Controller
{
use Concerns\HandlesTagsApi;
private const APPLICATION_SETTING_FIELDS = [
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'is_pr_deployments_public_enabled',
'stop_grace_period',
'docker_images_to_keep',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_raw_compose_deployment_enabled',
];
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'is_pr_deployments_public_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_raw_compose_deployment_enabled',
];
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
@ -87,9 +117,48 @@ private function removeSensitiveData($application)
$application->makeHidden(['value', 'real_value']);
}
if ($application->relationLoaded('settings')) {
$application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']);
}
return serializeApiResponse($application);
}
private function applicationSettingsFromRequest(Request $request): array
{
$settings = [];
foreach (self::APPLICATION_SETTING_FIELDS as $field) {
if (! array_key_exists($field, $request->all())) {
continue;
}
$settings[$field] = in_array($field, self::BOOLEAN_APPLICATION_SETTING_FIELDS, true)
? $request->boolean($field)
: $request->input($field);
}
return $settings;
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
return;
}
$regenerateLabels = ! $application->wasRecentlyCreated
&& $application->settings->is_container_label_readonly_enabled
&& (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings));
$application->settings->fill($settings)->save();
if ($regenerateLabels) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
}
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
@ -285,6 +354,20 @@ public function applications(Request $request)
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@ -453,6 +536,20 @@ public function create_public_application(Request $request)
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@ -621,6 +718,20 @@ public function create_private_gh_app_application(Request $request)
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@ -761,6 +872,20 @@ public function create_private_deploy_key_application(Request $request)
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@ -897,6 +1022,20 @@ public function create_dockerfile_application(Request $request)
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
@ -981,7 +1120,7 @@ private function create_application(Request $request, $type)
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', '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', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', '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', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@ -1036,6 +1175,7 @@ private function create_application(Request $request, $type)
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server;
$useBuildSecrets = $request->use_build_secrets;
$isStatic = $request->is_static;
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
@ -1045,6 +1185,19 @@ private function create_application(Request $request, $type)
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
$applicationSettings = $this->applicationSettingsFromRequest($request);
$requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-deploy-key'], true)
? $request->input('build_pack')
: $type;
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.',
],
], 422);
}
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@ -1084,6 +1237,12 @@ private function create_application(Request $request, $type)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@ -1232,6 +1391,7 @@ private function create_application(Request $request, $type)
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
$application->settings->save();
@ -1260,6 +1420,10 @@ private function create_application(Request $request, $type)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@ -1478,6 +1642,7 @@ private function create_application(Request $request, $type)
$application->repository_project_id = $repository_project_id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@ -1512,6 +1677,10 @@ private function create_application(Request $request, $type)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@ -1694,6 +1863,7 @@ private function create_application(Request $request, $type)
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@ -1728,6 +1898,10 @@ private function create_application(Request $request, $type)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@ -1837,6 +2011,7 @@ private function create_application(Request $request, $type)
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@ -1859,6 +2034,10 @@ private function create_application(Request $request, $type)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@ -1963,6 +2142,7 @@ private function create_application(Request $request, $type)
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@ -1985,6 +2165,10 @@ private function create_application(Request $request, $type)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isContainerLabelEscapeEnabled)) {
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
@ -2090,7 +2274,7 @@ public function application_by_uuid(Request $request)
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->with('settings')->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
@ -2405,11 +2589,24 @@ public function delete_by_uuid(Request $request)
],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'],
'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
],
)
),
@ -2495,7 +2692,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', 'is_preview_deployments_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', 'is_preserve_repository_enabled', 'include_source_commit_in_build'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_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', 'use_build_secrets', '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', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS];
$validationRules = [
'name' => 'string|max:255',
@ -2574,6 +2771,17 @@ public function update_by_uuid(Request $request)
], 422);
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.',
],
], 422);
}
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
$validationErrors = [];
@ -2728,6 +2936,7 @@ public function update_by_uuid(Request $request)
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$useBuildSecrets = $request->use_build_secrets;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
$includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build');
@ -2735,6 +2944,10 @@ public function update_by_uuid(Request $request)
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
}
if (isset($useBuildSecrets)) {
$application->settings->use_build_secrets = $useBuildSecrets;
$application->settings->save();
}
if (isset($isStatic)) {
$application->settings->is_static = $isStatic;
@ -2778,6 +2991,7 @@ public function update_by_uuid(Request $request)
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
}
$this->applyApplicationSettings($application, $applicationSettings);
removeUnnecessaryFieldsFromRequest($request);
$data = $request->only($allowedFields);
@ -4023,7 +4237,7 @@ public function action_restart(Request $request)
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {

View file

@ -850,6 +850,12 @@ public function create_backup(Request $request)
$this->authorize('manageBackups', $database);
if (! $database->isBackupSolutionAvailable()) {
return response()->json([
'message' => 'Scheduled backups are not supported for this database type.',
], 422);
}
// Validate frequency is a valid cron expression
$isValid = validate_cron_expression($request->frequency);
if (! $isValid) {
@ -915,6 +921,8 @@ public function create_backup(Request $request)
$backupData['databases_to_backup'] = $database->mysql_database;
} elseif ($database->type() === 'standalone-mariadb') {
$backupData['databases_to_backup'] = $database->mariadb_database;
} elseif ($database->type() === 'standalone-clickhouse') {
$backupData['databases_to_backup'] = $database->clickhouse_db;
}
}
@ -1805,6 +1813,12 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@ -3016,7 +3030,7 @@ public function list_backup_executions(Request $request)
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {

View file

@ -10,6 +10,7 @@
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class DestinationsController extends Controller
{
@ -59,6 +60,22 @@ private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDo
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
#[OA\Get(
summary: 'List destinations',
description: 'List all Docker network destinations for the authenticated team.',
path: '/destinations',
operationId: 'list-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
responses: [
new OA\Response(
response: 200,
description: 'Destinations for the authenticated team.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
],
)]
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@ -74,6 +91,26 @@ public function index(Request $request): JsonResponse
);
}
#[OA\Get(
summary: 'List destinations by server',
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'list-server-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destinations attached to the server.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@ -89,6 +126,26 @@ public function index_by_server(Request $request, string $server_uuid): JsonResp
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
#[OA\Get(
summary: 'Get destination',
description: 'Get a Docker network destination by UUID.',
path: '/destinations/{uuid}',
operationId: 'get-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination details.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@ -100,6 +157,40 @@ public function show(Request $request, string $uuid): JsonResponse
return response()->json($this->transform($destination));
}
#[OA\Post(
summary: 'Create destination',
description: 'Create a Docker network destination on a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'create-server-destination',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['network'],
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 255),
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
],
type: 'object',
),
),
responses: [
new OA\Response(
response: 201,
description: 'Destination created.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@ -183,6 +274,32 @@ private function isUniqueConstraintViolation(QueryException $exception): bool
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
#[OA\Delete(
summary: 'Delete destination',
description: 'Delete an unused Docker network destination.',
path: '/destinations/{uuid}',
operationId: 'delete-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination deleted.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
],
type: 'object',
),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Destination has attached resources.'),
],
)]
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();

View file

@ -15,6 +15,7 @@
use App\Services\DigitalOceanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class DigitalOceanController extends Controller
@ -283,6 +284,10 @@ public function createServer(Request $request): JsonResponse
return response()->json(['message' => 'Private key not found.'], 404);
}
$digitalOceanService = null;
$dropletId = null;
$server = null;
try {
$digitalOceanService = new DigitalOceanService($token->token);
$sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey);
@ -309,29 +314,41 @@ public function createServer(Request $request): JsonResponse
$droplet = $digitalOceanService->createDroplet($params);
$dropletId = (int) $droplet['id'];
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
if (! $ipAddress) {
throw new \Exception('No public IP address available for the new droplet.');
$server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server {
$server = Server::create([
'name' => $normalizedServerName,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
@ -341,15 +358,17 @@ public function createServer(Request $request): JsonResponse
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'digitalocean_droplet_id' => $dropletId,
'ip' => $ipAddress,
'ip' => $server->ip,
]);
return response()->json([
'uuid' => $server->uuid,
'digitalocean_droplet_id' => $dropletId,
'ip' => $ipAddress,
'ip' => $server->ip,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
@ -357,6 +376,8 @@ public function createServer(Request $request): JsonResponse
return $response;
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
logger()->error('Failed to create DigitalOcean server', [
'error' => $e->getMessage(),
]);
@ -365,6 +386,19 @@ public function createServer(Request $request): JsonResponse
}
}
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
{
if (! $digitalOceanService || ! $dropletId || $server) {
return;
}
try {
$digitalOceanService->deleteDroplet($dropletId);
} catch (\Throwable $e) {
report($e);
}
}
private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int
{
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);

View file

@ -143,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name
* so container ordering from Sentinel does not affect the hash.
* The snapshot completeness flag is included so a complete snapshot always
* dispatches after a partial snapshot. Sorted by name so container ordering
* from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
@ -157,6 +158,14 @@ private function containerStateHash(array $data): string
->values()
->all();
return hash('xxh128', json_encode($containers));
return hash('xxh128', json_encode([
'snapshot_complete' => $this->isCompleteSnapshot($data),
'containers' => $containers,
]));
}
private function isCompleteSnapshot(array $data): bool
{
return data_get($data, 'snapshot.complete', true) !== false;
}
}

View file

@ -736,6 +736,13 @@ public function update_server(Request $request)
], 422);
}
if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
], 422);
}
$server->update($updateFields);
if ($request->has('is_build_server')) {
$server->settings()->update([

View file

@ -424,6 +424,33 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
),
]
)]
#[OA\Post(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'post-service-application-logs-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'logs', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@ -463,7 +490,7 @@ public function logs_by_uuid(Request $request): JsonResponse
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$lines = normalizeLogLines($request->query('lines'));
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
@ -540,6 +567,34 @@ public function logs_by_uuid(Request $request): JsonResponse
),
]
)]
#[OA\Post(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'post-start-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@ -635,6 +690,32 @@ public function action_start(Request $request): JsonResponse
),
]
)]
#[OA\Post(
summary: 'Restart service application container',
description: 'Restarts a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'post-restart-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@ -727,6 +808,32 @@ public function action_restart(Request $request): JsonResponse
),
]
)]
#[OA\Post(
summary: 'Stop service application container',
description: 'Stops a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'post-stop-service-application-by-service-and-app-uuid',
security: [['bearerAuth' => []]],
tags: ['Service applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: new OA\JsonContent(
type: 'object',
properties: [new OA\Property(property: 'message', type: 'string')],
),
),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();

View file

@ -0,0 +1,452 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceDatabasesController extends Controller
{
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
{
$serviceDatabase->makeHidden([
'id',
'service',
'service_id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
$serialized = serializeApiResponse($serviceDatabase);
if ($serialized instanceof Collection) {
return $serialized->all();
}
return (array) $serialized;
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($request->route('uuid'))
->first();
}
private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase
{
return $service->databases()
->where('uuid', $request->route('database_uuid'))
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service databases',
description: 'List compose databases for a single service.',
path: '/services/{uuid}/databases',
operationId: 'list-service-databases-by-service-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$databases = $service->databases()
->get()
->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database));
return response()->json($databases);
}
#[OA\Get(
summary: 'Get service database',
description: 'Get a compose database by service UUID and database UUID.',
path: '/services/{uuid}/databases/{database_uuid}',
operationId: 'get-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize('view', $serviceDatabase);
return response()->json($this->removeSensitiveData($serviceDatabase));
}
#[OA\Patch(
summary: 'Update service database',
description: 'Update mutable fields for a compose service database.',
path: '/services/{uuid}/databases/{database_uuid}',
operationId: 'patch-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'human_name', type: 'string', nullable: true),
new OA\Property(property: 'description', type: 'string', nullable: true),
new OA\Property(property: 'image', type: 'string'),
new OA\Property(property: 'exclude_from_status', type: 'boolean'),
new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'),
new OA\Property(property: 'is_public', type: 'boolean'),
new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535),
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1),
],
additionalProperties: false,
)
),
responses: [
new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$invalidRequest = validateIncomingRequest($request);
if ($invalidRequest instanceof JsonResponse) {
return $invalidRequest;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize('update', $serviceDatabase);
$payload = $request->json()->all();
if (empty($payload)) {
$payload = $request->request->all();
}
$allowedFields = [
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_public',
'public_port',
'public_port_timeout',
];
$validator = Validator::make($payload, [
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'sometimes|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_public' => 'sometimes|boolean',
'public_port' => 'nullable|integer|min:1|max:65535',
'public_port_timeout' => 'nullable|integer|min:1',
]);
$extraFields = array_diff(array_keys($payload), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$server = $serviceDatabase->service->destination->server;
if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']],
], 422);
}
$isPublic = $payload['is_public'] ?? $serviceDatabase->is_public;
$publicPort = $payload['public_port'] ?? $serviceDatabase->public_port;
if ($isPublic && ! $publicPort) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['public_port' => ['A public port is required when the database is public.']],
], 422);
}
if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
}
$shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public;
$shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public;
$serviceDatabase->fill($payload);
$serviceDatabase->save();
$serviceDatabase->refresh();
updateCompose($serviceDatabase);
if ($shouldStartProxy) {
StartDatabaseProxy::dispatch($serviceDatabase);
} elseif ($shouldStopProxy) {
StopDatabaseProxy::dispatch($serviceDatabase);
}
auditLog('api.service_database.updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_database_uuid' => $serviceDatabase->uuid,
'changed_fields' => array_keys($payload),
]);
return response()->json($this->removeSensitiveData($serviceDatabase));
}
#[OA\Get(
summary: 'Get service database logs',
description: 'Get Docker logs for a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/logs',
operationId: 'get-service-database-logs-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
],
responses: [
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function logs(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'view');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase, $server] = $resolved;
$containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid;
if (getContainerStatus($server, $containerName) !== 'running') {
return response()->json(['message' => 'Service database container is not running.'], 400);
}
$lines = normalizeLogLines($request->query('lines'));
return response()->json([
'logs' => getContainerLogs($server, $containerName, $lines),
]);
}
#[OA\Post(
summary: 'Start or redeploy service database container',
description: 'Run docker compose up for a single compose database.',
path: '/services/{uuid}/databases/{database_uuid}/start',
operationId: 'start-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
],
responses: [
new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function start(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
DeployServiceApplication::dispatch(
$serviceDatabase,
$request->boolean('latest'),
$request->boolean('force'),
);
return response()->json(['message' => 'Service database deploy request queued.']);
}
#[OA\Post(
summary: 'Restart service database container',
description: 'Restart a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/restart',
operationId: 'restart-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function restart(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
RestartServiceApplication::dispatch($serviceDatabase);
return response()->json(['message' => 'Service database restart request queued.']);
}
#[OA\Post(
summary: 'Stop service database container',
description: 'Stop a compose database container.',
path: '/services/{uuid}/databases/{database_uuid}/stop',
operationId: 'stop-service-database-by-service-and-database-uuid',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 501, description: 'Swarm not supported.'),
]
)]
public function stop(Request $request): JsonResponse
{
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
if ($resolved instanceof JsonResponse) {
return $resolved;
}
[$serviceDatabase] = $resolved;
StopServiceApplication::dispatch($serviceDatabase);
return response()->json(['message' => 'Service database stop request queued.']);
}
private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
if (! $serviceDatabase) {
return response()->json(['message' => 'Service database not found.'], 404);
}
$this->authorize($ability, $serviceDatabase);
$server = $serviceDatabase->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json(['message' => 'Server is not functional.'], 400);
}
return [$serviceDatabase, $server];
}
}

View file

@ -444,6 +444,12 @@ public function create_service(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@ -501,7 +507,8 @@ public function create_service(Request $request)
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
$service = new Service($servicePayload);
$service->save();
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
@ -639,6 +646,12 @@ public function create_service(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@ -1053,11 +1066,6 @@ public function delete_by_uuid(Request $request)
properties: [
'name' => ['type' => 'string', 'description' => 'The service name.'],
'description' => ['type' => 'string', 'description' => 'The service description.'],
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
@ -1942,7 +1950,7 @@ public function delete_env_by_uuid(Request $request)
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {

View file

@ -15,6 +15,7 @@
use App\Services\VultrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class VultrController extends Controller
@ -52,6 +53,8 @@ private function getVultrToken(Request $request): CloudProviderToken|JsonRespons
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return $token;
}
@ -277,11 +280,17 @@ public function createServer(Request $request): JsonResponse
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$vultrService = null;
$vultrInstanceId = null;
$server = null;
try {
$vultrService = new VultrService($token->token);
$publicKey = $privateKey->getPublicKey();
@ -313,33 +322,41 @@ public function createServer(Request $request): JsonResponse
}
$vultrInstance = $vultrService->createInstance($params);
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0';
$vultrInstanceId = (string) $vultrInstance['id'];
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$ipAddress = $assignedIpAddress;
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
$server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server {
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstanceId,
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
@ -349,27 +366,48 @@ public function createServer(Request $request): JsonResponse
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
'vultr_instance_id' => $vultrInstanceId,
'ip' => $server->ip,
]);
return response()->json([
'uuid' => $server->uuid,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
'vultr_instance_id' => $vultrInstanceId,
'ip' => $server->ip,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
logger()->error('Failed to create Vultr server', [
'error' => $e->getMessage(),
]);
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
}
}
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
{
if (! $vultrService || ! $vultrInstanceId || $server) {
return;
}
try {
$vultrService->deleteInstance($vultrInstanceId);
} catch (\Throwable $e) {
report($e);
}
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);

View file

@ -12,6 +12,7 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
@ -53,11 +54,13 @@ public function handle(): void
/**
* Get all functional servers to check for orphaned containers.
*/
private function getServersToCheck(): \Illuminate\Support\Collection
private function getServersToCheck(): Collection
{
$query = Server::whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_reachable', true)
->where('ip', '!=', '1.2.3.4');
->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
if (isCloud()) {
$query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true);
@ -99,7 +102,7 @@ private function cleanupOrphanedContainersOnServer(Server $server): void
/**
* Get all PR containers on a server (containers with pullRequestId > 0).
*/
private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection
private function getPRContainersOnServer(Server $server): Collection
{
try {
$output = instant_remote_process([

View file

@ -8,6 +8,7 @@
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
@ -17,6 +18,7 @@
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use App\Support\ClickhouseBackupCommand;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -39,7 +41,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public Server $server;
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database;
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneClickhouse|ServiceDatabase $database;
public ?string $container_name = null;
@ -271,6 +273,8 @@ public function handle(): void
$databasesToBackup = [$this->database->mysql_database];
} elseif (str($databaseType)->contains('mariadb')) {
$databasesToBackup = [$this->database->mariadb_database];
} elseif ($this->database instanceof StandaloneClickhouse) {
$databasesToBackup = [$this->database->clickhouse_db];
} else {
return;
}
@ -294,6 +298,10 @@ public function handle(): void
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif ($this->database instanceof StandaloneClickhouse) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} else {
return;
}
@ -386,6 +394,17 @@ public function handle(): void
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} elseif ($this->database instanceof StandaloneClickhouse) {
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_clickhouse($database);
} else {
throw new \Exception('Unsupported database type');
}
@ -400,6 +419,9 @@ public function handle(): void
}
} catch (Throwable $e) {
// Local backup failed
if ($this->database instanceof StandaloneClickhouse) {
deleteBackupsLocally($this->backup_location, $this->server);
}
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
@ -642,6 +664,32 @@ private function backup_standalone_mariadb(string $database): void
}
}
private function backup_standalone_clickhouse(string $database): void
{
$archiveName = ltrim($this->backup_file, '/');
try {
$commands = ClickhouseBackupCommand::make(
containerName: $this->container_name,
database: $database,
archiveName: $archiveName,
backupDirectory: $this->backup_dir,
);
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
} catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
$cleanupCommand = ClickhouseBackupCommand::cleanup($this->container_name, $archiveName);
instant_remote_process([$cleanupCommand], $this->server, false, false, null, disableMultiplexing: true);
}
}
private function add_to_backup_output($output): void
{
if ($this->backup_output) {

View file

@ -311,6 +311,10 @@ public function handle()
}
}
if (! $this->isCompleteSnapshot()) {
return;
}
$this->updateProxyStatus();
$this->updateNotFoundApplicationStatus();
@ -329,6 +333,11 @@ public function handle()
$this->checkLogDrainContainer();
}
private function isCompleteSnapshot(): bool
{
return data_get($this->data, 'snapshot.complete', true) !== false;
}
private function loadApplications(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus;
$database->save();
}
if (! $this->isCompleteSnapshot()) {
return;
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';

View file

@ -457,7 +457,9 @@ private function processDockerCleanup(Server $server): void
private function getServersForCleanupQuery(): Builder
{
$query = Server::with('settings')
->where('ip', '!=', '1.2.3.4');
->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
if (isCloud()) {
$query

View file

@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function __construct(public Server $server)
{
$this->onQueue('high');
}
public function middleware(): array
{
return [(new WithoutOverlapping('server-cloud-provider-status-'.$this->server->uuid))->expireAfter(130)->dontRelease()];
}
public function handle(): void
{
try {
if (! $this->server->cloudProviderToken) {
return;
}
match ($this->server->cloudProviderToken->provider) {
'hetzner' => $this->server->hetzner_server_id
? $this->server->refreshHetznerState()
: null,
'vultr' => $this->server->vultr_instance_id
? $this->server->refreshVultrState()
: null,
'digitalocean' => $this->server->digitalocean_droplet_id
? $this->server->refreshDigitalOceanState()
: null,
default => null,
};
} catch (\Throwable $e) {
Log::debug('Cloud provider status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -6,7 +6,6 @@
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
use App\Services\HetznerService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -42,8 +41,12 @@ private function disableSshMux(): void
$configRepository->disableSshMux();
}
public function handle()
public function handle(): void
{
if ($this->server->hasPlaceholderIp()) {
return;
}
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
@ -62,19 +65,6 @@ public function handle()
return;
}
// Check Hetzner server status if applicable
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
$this->checkHetznerStatus();
}
if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) {
$this->checkVultrStatus();
}
if ($this->server->digitalocean_droplet_id && $this->server->cloudProviderToken) {
$this->checkDigitalOceanStatus();
}
// Temporarily disable mux if requested
if ($this->disableMux) {
$this->disableSshMux();
@ -136,17 +126,6 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
@ -171,63 +150,6 @@ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $w
}
}
private function checkHetznerStatus(): void
{
$status = null;
try {
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} 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]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
throw new \Exception('Server is powered off');
}
}
}
private function checkVultrStatus(): void
{
try {
$status = $this->server->refreshVultrState();
} catch (\Throwable) {
// Silently ignore transient Vultr API errors.
return;
}
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
throw new \Exception('Vultr instance is not running');
}
}
private function checkDigitalOceanStatus(): void
{
try {
$status = $this->server->refreshDigitalOceanState();
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: DigitalOcean status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
return;
}
$this->server->digitalocean_droplet_status = $status;
if (in_array($status, ['off', 'archive', 'deleted'], true)) {
throw new \Exception('DigitalOcean droplet is not running');
}
}
private function checkConnection(): bool
{
try {

View file

@ -55,6 +55,13 @@ public function handle(): void
// Get all servers to process
$servers = $this->getServers();
// Provider state checks run independently so slow APIs cannot block SSH checks.
$this->dispatchCloudProviderStatusChecks($servers);
$servers = $servers
->reject(fn (Server $server) => $server->hasPlaceholderIp())
->values();
// Dispatch ServerConnectionCheck for all servers efficiently
$this->dispatchConnectionChecks($servers);
@ -64,24 +71,45 @@ public function handle(): void
private function getServers(): Collection
{
$allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4');
$allServers = Server::with(['settings', 'cloudProviderToken']);
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers()->with('settings')->get();
$own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get();
return $servers->merge($own);
return $servers->merge($own)->unique('id')->values();
} else {
return $allServers->get();
}
}
private function dispatchCloudProviderStatusChecks(Collection $servers): void
{
if (! shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) {
return;
}
$servers->each(function (Server $server) {
$hasCloudResource = $server->hetzner_server_id
|| $server->vultr_instance_id
|| $server->digitalocean_droplet_id;
if ($hasCloudResource && $server->cloudProviderToken) {
ServerCloudProviderStatusCheckJob::dispatch($server);
}
});
}
private function dispatchConnectionChecks(Collection $servers): void
{
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
if ($server->hasPlaceholderIp()) {
return;
}
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
return;

View file

@ -286,6 +286,7 @@ public function saveStopGracePeriod()
$this->application->settings->save();
$this->dispatch('success', 'Stop grace period updated.');
$this->dispatch('configurationChanged');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {

View file

@ -489,6 +489,7 @@ public function instantSave()
if ($this->isContainerLabelReadonlyEnabled) {
$this->resetDefaultLabels(false);
}
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -147,6 +147,7 @@ public function changeSource($sourceId, $sourceType)
'source_id' => $source->id,
'source_type' => $sourceType,
]);
$this->dispatch('configurationChanged');
['repository' => $customRepository] = $this->application->customRepository();
$repository = githubApi($this->application->source, "repos/{$customRepository}");

View file

@ -57,6 +57,7 @@ public function instantSave()
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -68,6 +69,7 @@ public function submit()
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -34,7 +34,7 @@ class CloneMe extends Component
public ?int $selectedServer = null;
public ?int $selectedDestination = null;
public ?string $selectedDestination = null;
public ?Server $server = null;
@ -76,9 +76,9 @@ public function render()
return view('livewire.project.clone-me');
}
public function selectServer($server_id, $destination_id)
public function selectServer($server_id, $destination_uuid)
{
if ($server_id == $this->selectedServer && $destination_id == $this->selectedDestination) {
if ($server_id == $this->selectedServer && $destination_uuid === $this->selectedDestination) {
$this->selectedServer = null;
$this->selectedDestination = null;
$this->server = null;
@ -86,7 +86,7 @@ public function selectServer($server_id, $destination_id)
return;
}
$this->selectedServer = $server_id;
$this->selectedDestination = $destination_id;
$this->selectedDestination = $destination_uuid;
$this->server = $this->servers->where('id', $server_id)->first();
}
@ -98,6 +98,10 @@ public function clone(string $type)
'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(),
]);
$selectedDestination = find_resource_destination_for_current_team($this->selectedDestination);
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
@ -130,7 +134,6 @@ public function clone(string $type)
$databases = $this->environment->databases();
$services = $this->environment->services;
foreach ($applications as $application) {
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
clone_application($application, $selectedDestination, [
'environment_id' => $environment->id,
], $this->cloneVolumeData);
@ -147,7 +150,8 @@ public function clone(string $type)
'status' => 'exited',
'started_at' => null,
'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination,
'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
]);
$newDatabase->save();
@ -265,7 +269,9 @@ public function clone(string $type)
])->fill([
'uuid' => $uuid,
'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination,
'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
'server_id' => $selectedDestination->server_id,
]);
$newService->save();

View file

@ -22,13 +22,7 @@ public function mount()
if (! $database) {
return redirect()->route('dashboard');
}
// No backups
if (
$database->getMorphClass() === \App\Models\StandaloneRedis::class ||
$database->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$database->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$database->getMorphClass() === \App\Models\StandaloneClickhouse::class
) {
if (! $database->isBackupSolutionAvailable()) {
return redirect()->route('project.database.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,

View file

@ -48,6 +48,12 @@ public function submit()
try {
$this->authorize('manageBackups', $this->database);
if (! $this->database->isBackupSolutionAvailable()) {
$this->dispatch('error', 'Scheduled backups are not supported for this database type.');
return;
}
$this->validate();
if ($this->saveToS3) {
@ -87,6 +93,8 @@ public function submit()
$payload['databases_to_backup'] = $this->database->mysql_database;
} elseif ($this->database->type() === 'standalone-mariadb') {
$payload['databases_to_backup'] = $this->database->mariadb_database;
} elseif ($this->database->type() === 'standalone-clickhouse') {
$payload['databases_to_backup'] = $this->database->clickhouse_db;
}
$databaseBackup = ScheduledDatabaseBackup::create($payload);

View file

@ -47,19 +47,20 @@ public function submit()
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
$service = new Service([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);
$service->save();
$variables = parseEnvFormatToArray($this->envFile);
foreach ($variables as $key => $data) {

View file

@ -115,7 +115,7 @@ public function submit()
$parser->parse($dockerImage);
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -133,7 +133,7 @@ public function submit()
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
$application = new Application([
'name' => 'docker-image-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
@ -147,6 +147,7 @@ public function submit()
'destination_type' => $destination_class,
'health_check_enabled' => false,
]);
$application->save();
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->update([

View file

@ -192,7 +192,7 @@ public function submit()
}
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -201,7 +201,7 @@ public function submit()
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$application = Application::create([
$application = new Application([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
'repository_project_id' => $this->selected_repository_id,
'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
@ -216,6 +216,7 @@ public function submit()
'source_id' => $this->github_app->id,
'source_type' => $this->github_app->getMorphClass(),
]);
$application->save();
$application->settings->is_static = $this->is_static;
$application->settings->save();

View file

@ -136,7 +136,7 @@ public function submit()
$this->validate();
try {
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -185,7 +185,8 @@ public function submit()
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = Application::create($application_init);
$application = new Application($application_init);
$application->save();
$application->settings->is_static = $this->is_static;
$application->settings->save();

View file

@ -290,7 +290,7 @@ public function submit()
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -336,7 +336,8 @@ public function submit()
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = Application::create($application_init);
$application = new Application($application_init);
$application->save();
$application->settings->is_static = $this->isStatic;
$application->settings->save();

View file

@ -24,6 +24,8 @@ class Select extends Component
public Collection|null|Server $servers;
public ?Collection $buildServers = null;
public bool $onlyBuildServerAvailable = false;
public ?Collection $standaloneDockers;
@ -380,7 +382,7 @@ public function setType(string $type)
return;
}
if (count($this->servers) === 1) {
if (count($this->servers) === 1 && $this->buildServers?->isEmpty()) {
$server = $this->servers->first();
if ($server instanceof Server) {
$this->setServer($server);
@ -452,12 +454,8 @@ public function whatToDoNext()
public function loadServers()
{
$this->servers = Server::isUsable()->get()->sortBy('name');
$this->allServers = $this->servers;
if ($this->allServers && $this->allServers->isNotEmpty()) {
$this->onlyBuildServerAvailable = $this->allServers->every(function ($server) {
return $server->isBuildServer();
});
}
$this->buildServers = Server::isUsableBuildServer()->get()->sortBy('name');
$this->allServers = $this->servers->concat($this->buildServers);
$this->onlyBuildServerAvailable = $this->servers->isEmpty() && $this->buildServers->isNotEmpty();
}
}

View file

@ -38,7 +38,7 @@ public function submit()
'dockerfile' => 'required',
]);
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -51,7 +51,7 @@ public function submit()
if (! $port) {
$port = 80;
}
$application = Application::create([
$application = new Application([
'name' => 'dockerfile-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
@ -66,6 +66,7 @@ public function submit()
'source_id' => 0,
'source_type' => GithubApp::class,
]);
$application->save();
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->update([

View file

@ -33,7 +33,7 @@ public function mount()
return redirect()->route('dashboard');
}
if (isset($type) && isset($destination_uuid)) {
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('dashboard');
}
@ -96,7 +96,8 @@ public function mount()
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
$service = new Service($service_payload);
$service->save();
$service->name = "$oneClickServiceName-".$service->uuid;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {

View file

@ -118,9 +118,8 @@ public function promote(int $network_id, int $server_id)
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination;
$mainDestination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class,
@ -128,7 +127,7 @@ public function promote(int $network_id, int $server_id)
$this->resource->additional_networks()
->wherePivot('server_id', $server->id)
->detach($network->id);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->resource->additional_networks()->attach($mainDestination->id, ['server_id' => $mainDestination->server->id]);
});
$this->resource->refresh();
$this->refreshServers();

View file

@ -76,6 +76,7 @@ public function instantSave()
$this->resource->settings->save();
$this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -152,6 +152,7 @@ public function instantSave()
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
$this->dispatch('configurationChanged');
}
public function submit()
@ -178,6 +179,7 @@ public function submit()
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -213,6 +215,7 @@ public function toggleHealthcheck()
} else {
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
}
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -11,7 +11,6 @@
use App\Models\Environment;
use App\Models\Project;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@ -19,7 +18,6 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -37,6 +35,8 @@ class ResourceOperations extends Component
public $servers;
public $buildServers;
public bool $cloneVolumeData = false;
public function mount()
@ -45,7 +45,9 @@ public function mount()
$this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid');
$this->projects = Project::ownedByCurrentTeamCached();
$this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer());
$servers = currentTeam()->servers()->get();
$this->servers = $servers->reject(fn ($server) => $server->isBuildServer());
$this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer());
}
public function toggleVolumeCloning(bool $value)
@ -53,20 +55,20 @@ public function toggleVolumeCloning(bool $value)
$this->cloneVolumeData = $value;
}
public function cloneTo($destination_id)
public function cloneTo($destination_uuid)
{
try {
$this->authorize('update', $this->resource);
$new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
$new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
$new_destination = find_resource_destination_for_current_team($destination_uuid);
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
}
$uuid = new_public_id();
$server = $new_destination->server;
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
@ -99,6 +101,7 @@ public function cloneTo($destination_id)
'status' => 'exited',
'started_at' => null,
'destination_id' => $new_destination->id,
'destination_type' => $new_destination->getMorphClass(),
]);
$new_resource->save();

View file

@ -14,6 +14,7 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -402,11 +403,11 @@ public function clearCloudInitScript(): void
}
/**
* @return array{droplet: array, ip: string|null}
* Create the droplet on DigitalOcean and return the raw droplet payload.
* The public IP may not be assigned yet at this point.
*/
private function createDigitalOceanDroplet(string $token): array
private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array
{
$digitalOceanService = new DigitalOceanService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
@ -442,20 +443,17 @@ private function createDigitalOceanDroplet(string $token): array
$params['user_data'] = $this->cloud_init_script;
}
$droplet = $digitalOceanService->createDroplet($params);
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
return [
'droplet' => $droplet,
'ip' => $ipAddress,
];
return $digitalOceanService->createDroplet($params);
}
public function submit()
{
$this->validate();
$digitalOceanService = null;
$dropletId = null;
$server = null;
try {
$this->authorize('create', Server::class);
@ -473,30 +471,46 @@ public function submit()
]);
}
$result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken());
$droplet = $result['droplet'];
$ipAddress = $result['ip'];
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
$dropletId = (int) $droplet['id'];
if (! $ipAddress) {
throw new \Exception('No public IP address available for the new droplet.');
// Persist the server immediately so the droplet is always tracked
// in Coolify, even if waiting for the public IP fails below.
$server = DB::transaction(function () use ($dropletId, $droplet): Server {
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'digitalocean_droplet_id' => $droplet['id'],
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
@ -506,10 +520,25 @@ public function submit()
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
return handleError($e, $this);
}
}
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
{
if (! $digitalOceanService || ! $dropletId || $server) {
return;
}
try {
$digitalOceanService->deleteDroplet($dropletId);
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
return view('livewire.server.new.by-digital-ocean');

View file

@ -717,20 +717,19 @@ public function submit()
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
if (! $ipAddress) {
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
}
// Create server in Coolify database
// Create server in Coolify database immediately so the Hetzner
// server is always tracked, even when no IP is assigned yet —
// the server page polling backfills the placeholder IP later.
$server = Server::create([
'name' => $this->server_name,
'ip' => $ipAddress,
'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'],
'hetzner_server_status' => $hetznerServer['status'] ?? null,
]);
$server->proxy->set('status', 'exited');

View file

@ -14,6 +14,7 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -377,9 +378,8 @@ private function providerDataErrorMessage(string $providerName, \Throwable $e, s
return "{$providerName} API error: {$details}";
}
private function createVultrServer(string $token): array
private function createVultrServer(VultrService $vultrService): array
{
$vultrService = new VultrService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
@ -419,6 +419,10 @@ public function submit(): mixed
return null;
}
$vultrService = null;
$vultrInstanceId = null;
$server = null;
try {
$this->authorize('create', Server::class);
@ -437,33 +441,43 @@ public function submit(): mixed
}
$vultrService = new VultrService($this->getVultrToken());
$vultrInstance = $this->createVultrServer($this->getVultrToken());
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0';
$vultrInstance = $this->createVultrServer($vultrService);
$vultrInstanceId = (string) $vultrInstance['id'];
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
$server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server {
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'vultr_instance_id' => $vultrInstanceId,
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
if ($this->from_onboarding) {
currentTeam()->update([
@ -474,10 +488,25 @@ public function submit(): mixed
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
return handleError($e, $this);
}
}
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
{
if (! $vultrService || ! $vultrInstanceId || $server) {
return;
}
try {
$vultrService->deleteInstance($vultrInstanceId);
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
return view('livewire.server.new.by-vultr');

View file

@ -202,7 +202,7 @@ public function mount(string $server_uuid)
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->syncData();
if (! $this->server->isEmpty()) {
if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) {
$this->isBuildServerLocked = true;
}
// Load saved Hetzner status and validation state
@ -409,6 +409,12 @@ public function updatedIsBuildServer($value)
{
try {
$this->authorize('update', $this->server);
if ($value === true && ! $this->server->isEmpty()) {
$this->isBuildServer = false;
$this->dispatch('error', 'A server with existing resources cannot be configured as a build server.');
return;
}
if ($value === true && $this->isSentinelEnabled) {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;
@ -488,6 +494,11 @@ public function checkHetznerServerStatus(bool $manual = false)
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
}
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
if ($this->server->backfillPlaceholderIp($assignedIp)) {
$this->ip = $this->server->ip;
}
if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
}
@ -606,6 +617,7 @@ public function startHetznerServer()
public function startVultrInstance()
{
try {
$this->authorize('update', $this->server);
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');

View file

@ -111,6 +111,7 @@
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'),
]
)]

View file

@ -4,7 +4,49 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Application settings.',
type: 'object',
properties: [
'is_static' => ['type' => 'boolean'],
'is_git_submodules_enabled' => ['type' => 'boolean'],
'is_git_lfs_enabled' => ['type' => 'boolean'],
'is_auto_deploy_enabled' => ['type' => 'boolean'],
'is_force_https_enabled' => ['type' => 'boolean'],
'is_debug_enabled' => ['type' => 'boolean'],
'is_preview_deployments_enabled' => ['type' => 'boolean'],
'is_log_drain_enabled' => ['type' => 'boolean'],
'is_gpu_enabled' => ['type' => 'boolean'],
'gpu_driver' => ['type' => 'string', 'nullable' => true],
'gpu_count' => ['type' => 'string', 'nullable' => true],
'gpu_device_ids' => ['type' => 'string', 'nullable' => true],
'gpu_options' => ['type' => 'string', 'nullable' => true],
'is_include_timestamps' => ['type' => 'boolean'],
'is_swarm_only_worker_nodes' => ['type' => 'boolean'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean'],
'is_build_server_enabled' => ['type' => 'boolean'],
'is_consistent_container_name_enabled' => ['type' => 'boolean'],
'is_gzip_enabled' => ['type' => 'boolean'],
'is_stripprefix_enabled' => ['type' => 'boolean'],
'connect_to_docker_network' => ['type' => 'boolean'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true],
'is_container_label_escape_enabled' => ['type' => 'boolean'],
'is_env_sorting_enabled' => ['type' => 'boolean'],
'is_container_label_readonly_enabled' => ['type' => 'boolean'],
'is_preserve_repository_enabled' => ['type' => 'boolean'],
'disable_build_cache' => ['type' => 'boolean'],
'is_spa' => ['type' => 'boolean'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean'],
'use_build_secrets' => ['type' => 'boolean'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean'],
'include_source_commit_in_build' => ['type' => 'boolean'],
'docker_images_to_keep' => ['type' => 'integer'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true],
]
)]
class ApplicationSetting extends Model
{
protected $casts = [
@ -27,6 +69,17 @@ class ApplicationSetting extends Model
'is_git_shallow_clone_enabled' => 'boolean',
'docker_images_to_keep' => 'integer',
'stop_grace_period' => 'integer',
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'is_include_timestamps' => 'boolean',
'is_swarm_only_worker_nodes' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'is_consistent_container_name_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'connect_to_docker_network' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
];
protected $fillable = [

View file

@ -18,6 +18,7 @@
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
@ -112,6 +113,15 @@ class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
/**
* Sentinel IP for servers that do not have a real address yet
* (cloud provisioning in progress or parked as unreachable).
* Scheduled jobs skip these servers via skipServer().
*/
public const PLACEHOLDER_IP = '1.2.3.4';
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
public static $batch_counter = 0;
/**
@ -307,6 +317,85 @@ public function type()
return 'server';
}
public function hasPlaceholderIp(): bool
{
// Cast: the saving hook stores the ip as a Stringable in memory.
return self::isPlaceholderIp((string) $this->ip);
}
public static function isPlaceholderIp(?string $ip): bool
{
return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true);
}
/**
* Replace a placeholder IP with the real address once the cloud
* provider reports one. Returns true when the IP was updated.
*/
public function backfillPlaceholderIp(?string $ip): bool
{
if (self::isPlaceholderIp($ip)) {
return false;
}
$updated = static::query()
->whereKey($this->getKey())
->where(function (Builder $query): void {
$query->whereNull('ip')
->orWhere('ip', '')
->orWhereIn('ip', self::PLACEHOLDER_IPS);
})
->update(['ip' => $ip]);
if ($updated === 0) {
return false;
}
$this->forceFill(['ip' => $ip]);
$this->syncOriginalAttribute('ip');
static::flushIdentityMap();
return true;
}
/**
* Persist provider status without saving a stale in-memory IP value.
*
* @param array<string, mixed> $updates
*/
private function persistProviderState(array $updates): void
{
if (empty($updates)) {
return;
}
static::query()->whereKey($this->getKey())->update($updates);
$this->forceFill($updates);
$this->syncOriginalAttributes(array_keys($updates));
static::flushIdentityMap();
}
public function refreshHetznerState(): ?string
{
if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') {
return $this->hetzner_server_status;
}
$hetznerService = new HetznerService($this->cloudProviderToken->token);
$server = $hetznerService->getServer($this->hetzner_server_id);
$status = $server['status'] ?? null;
$assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip');
$updates = [];
if ($this->hetzner_server_status !== $status) {
$updates['hetzner_server_status'] = $status;
}
$this->persistProviderState($updates);
$this->backfillPlaceholderIp($assignedIp);
return $status;
}
public function refreshVultrState(): ?string
{
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
@ -322,8 +411,7 @@ public function refreshVultrState(): ?string
}
if ($this->vultr_instance_status !== 'deleted') {
$this->update(['vultr_instance_status' => 'deleted']);
$this->forceFill(['vultr_instance_status' => 'deleted']);
$this->persistProviderState(['vultr_instance_status' => 'deleted']);
}
return 'deleted';
@ -338,16 +426,8 @@ public function refreshVultrState(): ?string
if ($this->vultr_instance_status !== $status) {
$updates['vultr_instance_status'] = $status;
}
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
if ($hasPlaceholderIp && $publicIp) {
$updates['ip'] = $publicIp;
}
if (! empty($updates)) {
$this->update($updates);
$this->forceFill($updates);
}
$this->persistProviderState($updates);
$this->backfillPlaceholderIp($publicIp);
return $status;
}
@ -364,7 +444,7 @@ public function refreshDigitalOceanState(): ?string
$droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id);
} catch (RequestException $e) {
if ($e->response?->status() === 404) {
$this->update(['digitalocean_droplet_status' => 'deleted']);
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
return 'deleted';
}
@ -372,7 +452,7 @@ public function refreshDigitalOceanState(): ?string
throw $e;
} catch (\Throwable $e) {
if ((int) $e->getCode() === 404) {
$this->update(['digitalocean_droplet_status' => 'deleted']);
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
return 'deleted';
}
@ -387,12 +467,8 @@ public function refreshDigitalOceanState(): ?string
$status = $droplet['status'] ?? null;
$ip = $digitalOceanService->getPublicIpAddress($droplet);
$updates = ['digitalocean_droplet_status' => $status];
if ($ip && $ip !== $this->ip) {
$updates['ip'] = $ip;
}
$this->update($updates);
$this->persistProviderState(['digitalocean_droplet_status' => $status]);
$this->backfillPlaceholderIp($ip);
return $status;
}
@ -433,9 +509,29 @@ public static function ownedByCurrentTeamCached()
});
}
public static function isUsable()
public static function isUsable(): Builder
{
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false);
return self::usableByBuildServerStatus(false);
}
public static function isUsableBuildServer(): Builder
{
return self::usableByBuildServerStatus(true);
}
private static function usableByBuildServerStatus(bool $isBuildServer): Builder
{
return Server::ownedByCurrentTeam()
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
->whereRelation('settings', 'is_build_server', $isBuildServer)
->whereRelation('settings', 'force_disabled', false);
}
public function canHostResources(): bool
{
return ! $this->isBuildServer();
}
public function settings()
@ -1176,7 +1272,7 @@ public function isProxyShouldRun()
public function skipServer()
{
if ($this->ip === '1.2.3.4') {
if ($this->hasPlaceholderIp()) {
return true;
}
if ($this->settings->force_disabled === true) {
@ -1188,7 +1284,7 @@ public function skipServer()
public function isFunctional()
{
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp();
if ($isFunctional === false) {
Storage::disk('ssh-mux')->delete($this->muxFilename());

View file

@ -109,6 +109,7 @@ class ServerSetting extends Model
'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_build_server' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',

View file

@ -33,6 +33,13 @@ class ServiceDatabase extends BaseModel
];
protected $casts = [
'exclude_from_status' => 'boolean',
'is_public' => 'boolean',
'is_log_drain_enabled' => 'boolean',
'is_include_timestamps' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'public_port' => 'integer',
'public_port_timeout' => 'integer',
];

View file

@ -370,6 +370,6 @@ public function scheduledBackups()
public function isBackupSolutionAvailable()
{
return false;
return true;
}
}

View file

@ -7,7 +7,22 @@
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Destination',
description: 'A Docker network destination attached to a server.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'network', type: 'string'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
new OA\Property(property: 'server_uuid', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
],
)]
class StandaloneDocker extends BaseModel
{
use HasFactory;
@ -23,6 +38,10 @@ protected static function boot()
{
parent::boot();
static::created(function ($newStandaloneDocker) {
if (app()->runningUnitTests()) {
return;
}
$server = $newStandaloneDocker->server;
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([

View file

@ -231,13 +231,15 @@ public function isAnyNotificationEnabled()
$this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()
public function subscriptionEnded(?Subscription $subscription = null): void
{
if (! $this->subscription) {
$subscription ??= $this->subscription;
if (! $subscription) {
return;
}
$this->subscription->update([
$subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,

View file

@ -32,6 +32,14 @@ public function update(User $user, ServiceDatabase $serviceDatabase): bool
return Gate::allows('update', $serviceDatabase->service);
}
/**
* Determine whether the user can deploy or run lifecycle actions on the parent service stack.
*/
public function deploy(User $user, ServiceDatabase $serviceDatabase): bool
{
return Gate::allows('deploy', $serviceDatabase->service);
}
/**
* Determine whether the user can delete the model.
*/

View file

@ -102,6 +102,8 @@ private function sourceItems(): array
$this->item('git_repository', 'Repository', $this->application->git_repository, 'build'),
$this->item('git_branch', 'Branch', $this->application->git_branch, 'build'),
$this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'),
$this->item('source_id', 'Source ID', $this->application->source_id, 'build'),
$this->item('source_type', 'Source type', $this->application->source_type, 'build'),
$this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'),
];
}
@ -113,6 +115,8 @@ private function buildItems(): array
{
return [
$this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'),
$this->item('is_static', 'Static site', data_get($this->application, 'settings.is_static'), 'build'),
$this->item('is_spa', 'Single-page application', data_get($this->application, 'settings.is_spa'), 'build'),
$this->item('static_image', 'Static image', $this->application->static_image, 'build'),
$this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'),
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
@ -127,7 +131,11 @@ private function buildItems(): array
// so comparing it would flag a permanent change for git-based compose apps.
$this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
$this->item('is_git_submodules_enabled', 'Git submodules', data_get($this->application, 'settings.is_git_submodules_enabled'), 'build'),
$this->item('is_git_lfs_enabled', 'Git LFS', data_get($this->application, 'settings.is_git_lfs_enabled'), 'build'),
$this->item('is_git_shallow_clone_enabled', 'Shallow clone', data_get($this->application, 'settings.is_git_shallow_clone_enabled'), 'build'),
$this->item('is_env_sorting_enabled', 'Sort environment variables', data_get($this->application, 'settings.is_env_sorting_enabled'), 'build'),
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'redeploy'),
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
$this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'),
$this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'),
@ -142,13 +150,26 @@ private function buildItems(): array
private function runtimeItems(): array
{
return [
$this->item('docker_registry_image_name', 'Docker image', $this->application->docker_registry_image_name, 'redeploy'),
$this->item('docker_registry_image_tag', 'Docker image tag or hash', $this->application->docker_registry_image_tag, 'redeploy'),
$this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'),
$this->item('pre_deployment_command', 'Pre-deployment command', $this->application->pre_deployment_command, 'redeploy'),
$this->item('pre_deployment_command_container', 'Pre-deployment command container', $this->application->pre_deployment_command_container, 'redeploy'),
$this->item('post_deployment_command', 'Post-deployment command', $this->application->post_deployment_command, 'redeploy'),
$this->item('post_deployment_command_container', 'Post-deployment command container', $this->application->post_deployment_command_container, 'redeploy'),
$this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'),
$this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'),
$this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'),
$this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'),
$this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'),
$this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'),
$this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'),
$this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'),
$this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'),
$this->item('is_log_drain_enabled', 'Log drain', data_get($this->application, 'settings.is_log_drain_enabled'), 'redeploy'),
$this->item('is_swarm_only_worker_nodes', 'Swarm worker nodes only', data_get($this->application, 'settings.is_swarm_only_worker_nodes'), 'redeploy'),
$this->item('stop_grace_period', 'Stop grace period', $this->normalizedStopGracePeriod(), 'redeploy'),
$this->item('is_preserve_repository_enabled', 'Preserve repository', data_get($this->application, 'settings.is_preserve_repository_enabled'), 'redeploy'),
$this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'),
$this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'),
$this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'),
@ -170,7 +191,7 @@ private function domainItems(): array
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'build', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
@ -327,6 +348,17 @@ private function environmentDisplayValue(EnvironmentVariable $environmentVariabl
return $flags ? "Hidden ({$flags})" : 'Hidden';
}
private function normalizedStopGracePeriod(): ?int
{
$stopGracePeriod = data_get($this->application, 'settings.stop_grace_period');
if ($stopGracePeriod === null || (int) $stopGracePeriod === DEFAULT_STOP_GRACE_PERIOD_SECONDS) {
return null;
}
return (int) $stopGracePeriod;
}
private function environmentFlags(EnvironmentVariable $environmentVariable): string
{
return collect([

View file

@ -17,6 +17,28 @@ class ConfigurationDiffer
*/
private const IGNORED_KEYS = ['build.docker_compose'];
/**
* Defaults for fields introduced after configuration snapshots were first
* stored. Older snapshots omitted these keys, which should not make an
* unchanged default look like a pending configuration change.
*
* @var array<string, bool|array<int, bool>>
*/
private const INTRODUCED_DEFAULTS = [
'build.is_static' => false,
'build.is_spa' => false,
'build.is_git_submodules_enabled' => true,
'build.is_git_lfs_enabled' => true,
'build.is_git_shallow_clone_enabled' => true,
'build.is_env_sorting_enabled' => [false, true],
'runtime.is_consistent_container_name_enabled' => false,
'runtime.is_container_label_escape_enabled' => true,
'runtime.is_container_label_readonly_enabled' => true,
'runtime.is_log_drain_enabled' => false,
'runtime.is_swarm_only_worker_nodes' => true,
'runtime.is_preserve_repository_enabled' => false,
];
/**
* @param array<string, mixed> $previousSnapshot
* @param array<string, mixed> $currentSnapshot
@ -36,6 +58,14 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura
$previous = $previousItems[$key] ?? null;
$current = $currentItems[$key] ?? null;
if (
$previous === null
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
&& in_array((bool) data_get($current, 'compare_value'), (array) self::INTRODUCED_DEFAULTS[$key], true)
) {
continue;
}
if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) {
continue;
}

View file

@ -28,7 +28,7 @@ private function request(string $method, string $endpoint, array $data = []): ar
}
return $attempt * 100;
})
}, throw: false)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {

View file

@ -17,7 +17,7 @@ private function request(string $method, string $endpoint, array $data = []): ar
'Authorization' => 'Bearer '.$this->token,
])
->timeout(30)
->retry(3, fn (int $attempt) => $attempt * 100)
->retry(3, fn (int $attempt) => $attempt * 100, throw: false)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {

View file

@ -0,0 +1,37 @@
<?php
namespace App\Support;
final class ClickhouseBackupCommand
{
/** @return array<int, string> */
public static function make(
string $containerName,
string $database,
string $archiveName,
string $backupDirectory,
): array {
validateShellSafePath($database, 'database name');
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
$backupDirectory = rtrim($backupDirectory, '/');
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
$backupLocation = $backupDirectory.'/'.$archiveName;
$query = "BACKUP DATABASE `{$database}` TO File('{$archiveName}')";
return [
'mkdir -p '.escapeshellarg($backupDirectory),
'docker exec '.escapeshellarg($containerName).' clickhouse-client --query '.escapeshellarg($query),
'docker cp '.escapeshellarg($containerName.':'.$containerBackupPath).' '.escapeshellarg($backupLocation),
];
}
public static function cleanup(string $containerName, string $archiveName): string
{
validateFilenameSafe($archiveName, 'ClickHouse backup archive');
$containerBackupPath = '/var/lib/clickhouse/backups/'.$archiveName;
return 'docker exec '.escapeshellarg($containerName).' rm -f '.escapeshellarg($containerBackupPath);
}
}

View file

@ -117,6 +117,20 @@ function sharedDataApplications()
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_preview_deployments_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'is_git_submodules_enabled' => 'boolean',
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'is_pr_deployments_public_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
'docker_images_to_keep' => 'integer|min:0|max:100',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => ValidationPatterns::applicationDomainRules(),
'redirect' => Rule::enum(RedirectTypes::class),
@ -272,6 +286,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('github_app_uuid');
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('use_build_secrets');
$request->offsetUnset('is_static');
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
@ -283,6 +298,18 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('is_git_submodules_enabled');
$request->offsetUnset('is_git_lfs_enabled');
$request->offsetUnset('is_git_shallow_clone_enabled');
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
$request->offsetUnset('is_pr_deployments_public_enabled');
$request->offsetUnset('stop_grace_period');
$request->offsetUnset('docker_images_to_keep');
$request->offsetUnset('is_gzip_enabled');
$request->offsetUnset('is_stripprefix_enabled');
$request->offsetUnset('is_raw_compose_deployment_enabled');
$request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
}

View file

@ -220,6 +220,7 @@ function clone_application(Application $source, $destination, array $overrides =
'fqdn' => $url,
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $overrides));
$newApplication->save();

View file

@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string
return null;
}
[$localPart, $domain] = explode('@', Str::lower($email), 2);
$localPart = Str::before($localPart, '+');
$localPart = str_replace('.', '', $localPart);
[$localPart, $domain] = explode('@', Str::lower(trim($email)), 2);
if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) {
$localPart = Str::before($localPart, '+');
$localPart = str_replace('.', '', $localPart);
}
if (blank($localPart) || blank($domain)) {
return null;

View file

@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function find_resource_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
$destination = find_destination_for_current_team($uuid);
if (! $destination?->server?->canHostResources()) {
return null;
}
return $destination;
}
function showBoarding(): bool
{
if (isDev()) {

48
composer.lock generated
View file

@ -5189,16 +5189,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.2",
"version": "2.3.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"shasum": ""
},
"require": {
@ -5230,9 +5230,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
},
"time": "2026-01-25T14:56:51+00:00"
"time": "2026-07-08T07:01:06+00:00"
},
{
"name": "pion/laravel-chunk-upload",
@ -8228,16 +8228,16 @@
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.0",
"version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
@ -8275,7 +8275,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
@ -8295,7 +8295,7 @@
"type": "tidelift"
}
],
"time": "2026-04-13T15:52:40+00:00"
"time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/error-handler",
@ -10362,16 +10362,16 @@
},
{
"name": "symfony/serializer",
"version": "v8.0.10",
"version": "v8.0.14",
"source": {
"type": "git",
"url": "https://github.com/symfony/serializer.git",
"reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf"
"reference": "33d395158f1c3b6038738fbb8656e05ae7d2bf0d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf",
"reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf",
"url": "https://api.github.com/repos/symfony/serializer/zipball/33d395158f1c3b6038738fbb8656e05ae7d2bf0d",
"reference": "33d395158f1c3b6038738fbb8656e05ae7d2bf0d",
"shasum": ""
},
"require": {
@ -10436,7 +10436,7 @@
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/serializer/tree/v8.0.10"
"source": "https://github.com/symfony/serializer/tree/v8.0.14"
},
"funding": [
{
@ -10456,7 +10456,7 @@
"type": "tidelift"
}
],
"time": "2026-05-04T13:41:39+00:00"
"time": "2026-06-27T08:56:37+00:00"
},
{
"name": "symfony/service-contracts",
@ -11489,16 +11489,16 @@
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.3",
"version": "5.3.5",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f",
"shasum": ""
},
"require": {
@ -11559,7 +11559,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5"
},
"funding": [
{
@ -11571,7 +11571,7 @@
"type": "patreon"
}
],
"time": "2026-05-17T19:04:30+00:00"
"time": "2026-05-31T15:00:08+00:00"
},
{
"name": "webmozart/assert",

View file

@ -15,7 +15,7 @@ public function up(): void
DB::table('cloud_init_scripts')
->whereNull('uuid')
->orderBy('id')
->lazyById()
->each(function (object $script): void {
DB::table('cloud_init_scripts')
->where('id', $script->id)

View file

@ -12,7 +12,7 @@ ARG COOLIFY_CLI_VERSION=nightly
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
# https://nginx.org/en/linux_packages.html
ARG NGINX_VERSION=1.31.0-r1
ARG NGINX_VERSION=1.31.2-r1
# =================================================================
# Get MinIO client

View file

@ -12,7 +12,7 @@ ARG COOLIFY_CLI_VERSION=nightly
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
# https://nginx.org/en/linux_packages.html
ARG NGINX_VERSION=1.31.0-r1
ARG NGINX_VERSION=1.31.2-r1
# Add user/group
ARG USER_ID=9999

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@
<div class="box-title">{{ $user->name }}</div>
<div class="box-description">{{ $user->email }}</div>
<div class="box-description">Active:
{{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }}
{{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
</div>
</div>
</div>

View file

@ -25,13 +25,13 @@
@foreach ($servers->sortBy('id') as $server)
@foreach ($server->destinations() as $destination)
<tr class="cursor-pointer hover:bg-coolgray-50 dark:hover:bg-coolgray-200"
wire:click="selectServer('{{ $server->id }}', '{{ $destination->id }}')">
wire:click="selectServer('{{ $server->id }}', '{{ $destination->uuid }}')">
<td class="px-5 py-4 text-sm whitespace-nowrap dark:text-white"
:class="'{{ $selectedDestination === $destination->id }}' ?
:class="'{{ $selectedDestination === $destination->uuid }}' ?
'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'">
{{ $server->name }}</td>
<td class="px-5 py-4 text-sm whitespace-nowrap dark:text-white "
:class="'{{ $selectedDestination === $destination->id }}' ?
:class="'{{ $selectedDestination === $destination->uuid }}' ?
'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'">
{{ $destination->name }}
</td>

View file

@ -97,6 +97,10 @@
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneClickhouse')
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">

View file

@ -8,12 +8,7 @@
'label' => 'Backups',
'route' => 'project.database.backup.index',
'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'),
'visible' => in_array($database->getMorphClass(), [
'App\Models\StandalonePostgresql',
'App\Models\StandaloneMongodb',
'App\Models\StandaloneMysql',
'App\Models\StandaloneMariadb',
]),
'visible' => $database->isBackupSolutionAvailable(),
],
];
@ -200,11 +195,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
Terminal
</a>
@endcan
@if (
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$database->getMorphClass() === 'App\Models\StandaloneMysql' ||
$database->getMorphClass() === 'App\Models\StandaloneMariadb')
@if ($database->isBackupSolutionAvailable())
<a class="shrink-0 {{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.database.backup.index', $parameters) }}">
Backups

View file

@ -433,28 +433,40 @@ function searchResources() {
server. <a class="underline dark:text-white" href="/servers" {{ wireNavigate() }}>
Go to servers page
</a> </div>
@else
@forelse($servers as $server)
<div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}
</div>
@endif
@forelse($servers as $server)
<div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}
</div>
</div>
@empty
</div>
@empty
@if ($buildServers?->isEmpty() && ! $onlyBuildServerAvailable)
<div>
<div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers" {{ wireNavigate() }}>
Go to servers page
</a></div>
</div>
@endforelse
@endif
@endif
@endforelse
@foreach($buildServers ?? [] as $buildServer)
<div class="w-full coolbox opacity-60 cursor-not-allowed">
<div class="flex flex-col mx-6">
<div class="box-title">{{ $buildServer->name }}</div>
<div class="box-description">
This server is configured as a build server and cannot host resources.
<a class="underline dark:text-white" href="{{ route('server.show', ['server_uuid' => $buildServer->uuid]) }}"
{{ wireNavigate() }}>Change server settings</a>
</div>
</div>
</div>
@endforeach
</div>
@endif
@if ($current_step === 'destinations')

View file

@ -217,8 +217,9 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" />
</div>
@endcan
@can('update', $this->env)
<div class="flex flex-col w-full gap-3">
<div class="flex flex-wrap w-full items-center gap-4">
<div class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex min-w-0 flex-1 flex-col gap-3">
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
@if (!$isMagicVariable)
@ -266,16 +267,17 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" />
@endif
@endif
@endif
</div>
<x-environment-variable-warning :problematic-variables="$problematicVariables" />
</div>
<x-environment-variable-warning :problematic-variables="$problematicVariables" />
@if (!$isMagicVariable)
<div class="flex w-full justify-end gap-2">
<div class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0">
@if ($isDisabled)
<x-forms.button disabled type="submit">Update</x-forms.button>
<x-forms.button wire:click='lock'>Lock</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationText="{{ $key }}"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@ -284,14 +286,14 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" />
<x-forms.button wire:click='lock'>Lock</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationText="{{ $key }}"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif
</div>
@elseif ($type === 'service')
<div class="flex w-full justify-end gap-2">
<div class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0">
<x-forms.button wire:click='lock'>Lock</x-forms.button>
</div>
@endif

View file

@ -18,6 +18,7 @@
'destinations' => $s->destinations()->map(
fn($d) => [
'id' => $d->id,
'uuid' => $d->uuid,
'name' => $d->name,
'server_id' => $s->id,
],
@ -77,6 +78,9 @@
<template x-for="server in servers" :key="server.id">
<option :value="server.id" x-text="`${server.name} (${server.ip})`"></option>
</template>
@foreach ($buildServers as $buildServer)
<option disabled>{{ $buildServer->name }} Build server cannot host resources</option>
@endforeach
</select>
</div>
@ -84,8 +88,8 @@
<label class="block text-sm font-medium mb-2">Select Network Destination</label>
<select x-model="selectedCloneDestination" :disabled="!selectedCloneServer" class="select">
<option value="">Choose a destination...</option>
<template x-for="destination in availableDestinations" :key="destination.id">
<option :value="destination.id" x-text="destination.name"></option>
<template x-for="destination in availableDestinations" :key="destination.uuid">
<option :value="destination.uuid" x-text="destination.name"></option>
</template>
</select>
</div>

View file

@ -15,7 +15,7 @@
<h1>Server</h1>
<div class="pt-2 pb-4 md:pb-10">
<div class="flex-col md:flex-row flex gap-2">
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate">
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate text-neutral-600 dark:text-neutral-400">
{{ data_get($server, 'name') }}
</div>
@php

View file

@ -17,6 +17,7 @@
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServiceApplicationsController;
use App\Http\Controllers\Api\ServiceDatabasesController;
use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
@ -246,6 +247,14 @@
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/databases/{database_uuid}/restart', [ServiceDatabasesController::class, 'restart'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/databases/{database_uuid}/stop', [ServiceDatabasesController::class, 'stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);

View file

@ -7,7 +7,7 @@
services:
frontend:
image: ghcr.io/smaug6739/alexandrie-frontend:v8.7.2
image: ghcr.io/smaug6739/alexandrie-frontend:v8.10.0
environment:
- SERVICE_URL_FRONTEND_8200
- PORT=8200
@ -21,7 +21,7 @@ services:
- backend
backend:
image: ghcr.io/smaug6739/alexandrie-backend:v8.7.2
image: ghcr.io/smaug6739/alexandrie-backend:v8.10.0
environment:
- SERVICE_URL_BACKEND_8201
- BACKEND_PORT=8201
@ -74,7 +74,7 @@ services:
retries: 5
rustfs:
image: rustfs/rustfs:1.0.0-alpha.90
image: rustfs/rustfs:1.0.0-beta.8
environment:
- SERVICE_URL_RUSTFS_9000
- RUSTFS_ACCESS_KEY=${SERVICE_USER_RUSTFS}

View file

@ -7,7 +7,8 @@
services:
espocrm:
image: espocrm/espocrm:9
image: espocrm/espocrm:10
container_name: espocrm
environment:
- SERVICE_URL_ESPOCRM
- ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin}
@ -19,30 +20,39 @@ services:
- 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
- espocrm-data:/var/www/html/data
- espocrm-custom:/var/www/html/custom
- espocrm-custom-client:/var/www/html/client/custom
restart: unless-stopped
depends_on:
espocrm-db:
condition: service_healthy
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 20s
interval: 60s
timeout: 20s
retries: 3
espocrm-daemon:
image: espocrm/espocrm:9
image: espocrm/espocrm:10
container_name: espocrm-daemon
volumes:
- espocrm:/var/www/html
restart: always
volumes_from:
- espocrm
restart: unless-stopped
entrypoint: docker-daemon.sh
depends_on:
espocrm:
condition: service_healthy
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 20s
interval: 180s
timeout: 20s
retries: 3
espocrm-websocket:
image: espocrm/espocrm:9
image: espocrm/espocrm:10
container_name: espocrm-websocket
environment:
- SERVICE_URL_ESPOCRM_WEBSOCKET_8080
@ -50,16 +60,23 @@ services:
- 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
volumes_from:
- espocrm
restart: unless-stopped
entrypoint: docker-websocket.sh
depends_on:
espocrm:
condition: service_healthy
healthcheck:
test: ["CMD", "bin/command", "app-check"]
start_period: 20s
interval: 180s
timeout: 20s
retries: 3
espocrm-db:
image: mariadb:11.8
image: mariadb:12.3
container_name: espocrm-db
environment:
- MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm}
- MARIADB_USER=${SERVICE_USER_MARIADB}
@ -67,6 +84,7 @@ services:
- MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT}
volumes:
- espocrm-db:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 20s

View file

@ -53,7 +53,7 @@
"alexandrie": {
"documentation": "https://github.com/Smaug6739/Alexandrie/tree/main/docs?utm_source=coolify.io",
"slogan": "A powerful Markdown workspace designed for speed, clarity, and creativity.",
"compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfRlJPTlRFTkRfODIwMAogICAgICAtIFBPUlQ9ODIwMAogICAgICAtICdOVVhUX1BVQkxJQ19DT05GSUdfRElTQUJMRV9TSUdOVVBfUEFHRT0ke0NPTkZJR19ESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19DT05GSUdfRElTQUJMRV9MQU5ESU5HX1BBR0U9JHtDT05GSUdfRElTQUJMRV9MQU5ESU5HOi1mYWxzZX0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQVBJPSR7U0VSVklDRV9VUkxfQkFDS0VORH0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQ0ROPSR7U0VSVklDRV9VUkxfUlVTVEZTfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ0ROX0VORFBPSU5UPSR7Q0ROX0VORFBPSU5UOi0vYWxleGFuZHJpZS99JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0ZST05URU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYmFja2VuZAogIGJhY2tlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtYmFja2VuZDp2OC43LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CQUNLRU5EXzgyMDEKICAgICAgLSBCQUNLRU5EX1BPUlQ9ODIwMQogICAgICAtIEdJTl9NT0RFPXJlbGVhc2UKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnQ09PS0lFX0RPTUFJTj0ke1NFUlZJQ0VfVVJMX0ZST05URU5EfScKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfRlJPTlRFTkR9JwogICAgICAtICdBTExPV19VTlNFQ1VSRT0ke0FMTE9XX1VOU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1teXNxbAogICAgICAtIERBVEFCQVNFX1BPUlQ9MzMwNgogICAgICAtICdEQVRBQkFTRV9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTUlOSU9fRU5EUE9JTlQ9cnVzdGZzOjkwMDAnCiAgICAgIC0gJ01JTklPX1BVQkxJQ19VUkw9JHtTRVJWSUNFX1VSTF9SVVNURlN9JwogICAgICAtICdNSU5JT19TRUNVUkU9JHtNSU5JT19TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnTUlOSU9fQUNDRVNTS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1JFVEtFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fQlVDS0VUPSR7TUlOSU9fQlVDS0VUOi1hbGV4YW5kcmllfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUOi19JwogICAgICAtICdTTVRQX01BSUw9JHtTTVRQX01BSUw6LX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEOi19JwogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnVzdGZzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjguMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotYWxleGFuZHJpZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIGxvY2FsaG9zdAogICAgICAgIC0gJy11JwogICAgICAgIC0gcm9vdAogICAgICAgIC0gJy1wJHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgcmV0cmllczogNQogIHJ1c3RmczoKICAgIGltYWdlOiAncnVzdGZzL3J1c3RmczoxLjAuMC1hbHBoYS45MCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZST05URU5EXzgyMDAKICAgICAgLSBQT1JUPTgyMDAKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfU0lHTlVQX1BBR0U9JHtDT05GSUdfRElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfTEFORElOR19QQUdFPSR7Q09ORklHX0RJU0FCTEVfTEFORElORzotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0FQST0ke1NFUlZJQ0VfVVJMX0JBQ0tFTkR9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0NETj0ke1NFUlZJQ0VfVVJMX1JVU1RGU30nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NETl9FTkRQT0lOVD0ke0NETl9FTkRQT0lOVDotL2FsZXhhbmRyaWUvfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX1VSTF9GUk9OVEVORH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGJhY2tlbmQKICBiYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3NtYXVnNjczOS9hbGV4YW5kcmllLWJhY2tlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfODIwMQogICAgICAtIEJBQ0tFTkRfUE9SVD04MjAxCiAgICAgIC0gR0lOX01PREU9cmVsZWFzZQogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdDT09LSUVfRE9NQUlOPSR7U0VSVklDRV9VUkxfRlJPTlRFTkR9JwogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX1VSTF9GUk9OVEVORH0nCiAgICAgIC0gJ0FMTE9XX1VOU0VDVVJFPSR7QUxMT1dfVU5TRUNVUkU6LWZhbHNlfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPW15c3FsCiAgICAgIC0gREFUQUJBU0VfUE9SVD0zMzA2CiAgICAgIC0gJ0RBVEFCQVNFX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotYWxleGFuZHJpZS1kYn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdEQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNSU5JT19FTkRQT0lOVD1ydXN0ZnM6OTAwMCcKICAgICAgLSAnTUlOSU9fUFVCTElDX1VSTD0ke1NFUlZJQ0VfVVJMX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1VSRT0ke01JTklPX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdNSU5JT19BQ0NFU1NLRVk9JHtTRVJWSUNFX1VTRVJfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDUkVUS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdNSU5JT19CVUNLRVQ9JHtNSU5JT19CVUNLRVQ6LWFsZXhhbmRyaWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1Q6LX0nCiAgICAgIC0gJ1NNVFBfTUFJTD0ke1NNVFBfTUFJTDotfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkQ6LX0nCiAgICBkZXBlbmRzX29uOgogICAgICBteXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBydXN0ZnM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnLXUnCiAgICAgICAgLSByb290CiAgICAgICAgLSAnLXAke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICByZXRyaWVzOiA1CiAgcnVzdGZzOgogICAgaW1hZ2U6ICdydXN0ZnMvcnVzdGZzOjEuMC4wLWJldGEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"note-taking",
"markdown",
@ -1310,7 +1310,7 @@
"espocrm": {
"documentation": "https://docs.espocrm.com?utm_source=coolify.io",
"slogan": "EspoCRM is a free and open-source CRM platform.",
"compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK",
"compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0VTUE9DUk0KICAgICAgLSAnRVNQT0NSTV9BRE1JTl9VU0VSTkFNRT0ke0VTUE9DUk1fQURNSU5fVVNFUk5BTUU6LWFkbWlufScKICAgICAgLSAnRVNQT0NSTV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfUExBVEZPUk09TXlzcWwKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX0hPU1Q9ZXNwb2NybS1kYgogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX05BTUU9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYXRhOi92YXIvd3d3L2h0bWwvZGF0YScKICAgICAgLSAnZXNwb2NybS1jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICAgIC0gJ2VzcG9jcm0tY3VzdG9tLWNsaWVudDovdmFyL3d3dy9odG1sL2NsaWVudC9jdXN0b20nCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYWVtb24KICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLWRhZW1vbi5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTIuMycKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYjovdmFyL2xpYi9teXNxbCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK",
"tags": [
"crm",
"self-hosted",

View file

@ -53,7 +53,7 @@
"alexandrie": {
"documentation": "https://github.com/Smaug6739/Alexandrie/tree/main/docs?utm_source=coolify.io",
"slogan": "A powerful Markdown workspace designed for speed, clarity, and creativity.",
"compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0ZST05URU5EXzgyMDAKICAgICAgLSBQT1JUPTgyMDAKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfU0lHTlVQX1BBR0U9JHtDT05GSUdfRElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ09ORklHX0RJU0FCTEVfTEFORElOR19QQUdFPSR7Q09ORklHX0RJU0FCTEVfTEFORElORzotZmFsc2V9JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX0FQST0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9DRE49JHtTRVJWSUNFX0ZRRE5fUlVTVEZTfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQ0ROX0VORFBPSU5UPSR7Q0ROX0VORFBPSU5UOi0vYWxleGFuZHJpZS99JwogICAgICAtICdOVVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9GUk9OVEVORH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGJhY2tlbmQKICBiYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3NtYXVnNjczOS9hbGV4YW5kcmllLWJhY2tlbmQ6djguNy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JBQ0tFTkRfODIwMQogICAgICAtIEJBQ0tFTkRfUE9SVD04MjAxCiAgICAgIC0gR0lOX01PREU9cmVsZWFzZQogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdDT09LSUVfRE9NQUlOPSR7U0VSVklDRV9GUUROX0ZST05URU5EfScKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0ZST05URU5EfScKICAgICAgLSAnQUxMT1dfVU5TRUNVUkU9JHtBTExPV19VTlNFQ1VSRTotZmFsc2V9JwogICAgICAtIERBVEFCQVNFX0hPU1Q9bXlzcWwKICAgICAgLSBEQVRBQkFTRV9QT1JUPTMzMDYKICAgICAgLSAnREFUQUJBU0VfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01JTklPX0VORFBPSU5UPXJ1c3Rmczo5MDAwJwogICAgICAtICdNSU5JT19QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX1NFQ1VSRT0ke01JTklPX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdNSU5JT19BQ0NFU1NLRVk9JHtTRVJWSUNFX1VTRVJfUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDUkVUS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdNSU5JT19CVUNLRVQ9JHtNSU5JT19CVUNLRVQ6LWFsZXhhbmRyaWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1Q6LX0nCiAgICAgIC0gJ1NNVFBfTUFJTD0ke1NNVFBfTUFJTDotfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkQ6LX0nCiAgICBkZXBlbmRzX29uOgogICAgICBteXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBydXN0ZnM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1hbGV4YW5kcmllLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnLXUnCiAgICAgICAgLSByb290CiAgICAgICAgLSAnLXAke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICByZXRyaWVzOiA1CiAgcnVzdGZzOgogICAgaW1hZ2U6ICdydXN0ZnMvcnVzdGZzOjEuMC4wLWFscGhhLjkwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"compose": "c2VydmljZXM6CiAgZnJvbnRlbmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vc21hdWc2NzM5L2FsZXhhbmRyaWUtZnJvbnRlbmQ6djguMTAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUk9OVEVORF84MjAwCiAgICAgIC0gUE9SVD04MjAwCiAgICAgIC0gJ05VWFRfUFVCTElDX0NPTkZJR19ESVNBQkxFX1NJR05VUF9QQUdFPSR7Q09ORklHX0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NPTkZJR19ESVNBQkxFX0xBTkRJTkdfUEFHRT0ke0NPTkZJR19ESVNBQkxFX0xBTkRJTkc6LWZhbHNlfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9BUEk9JHtTRVJWSUNFX0ZRRE5fQkFDS0VORH0nCiAgICAgIC0gJ05VWFRfUFVCTElDX0JBU0VfQ0ROPSR7U0VSVklDRV9GUUROX1JVU1RGU30nCiAgICAgIC0gJ05VWFRfUFVCTElDX0NETl9FTkRQT0lOVD0ke0NETl9FTkRQT0lOVDotL2FsZXhhbmRyaWUvfScKICAgICAgLSAnTlVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9zbWF1ZzY3MzkvYWxleGFuZHJpZS1iYWNrZW5kOnY4LjEwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkFDS0VORF84MjAxCiAgICAgIC0gQkFDS0VORF9QT1JUPTgyMDEKICAgICAgLSBHSU5fTU9ERT1yZWxlYXNlCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0NPT0tJRV9ET01BSU49JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX0ZRRE5fRlJPTlRFTkR9JwogICAgICAtICdBTExPV19VTlNFQ1VSRT0ke0FMTE9XX1VOU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1teXNxbAogICAgICAtIERBVEFCQVNFX1BPUlQ9MzMwNgogICAgICAtICdEQVRBQkFTRV9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTUlOSU9fRU5EUE9JTlQ9cnVzdGZzOjkwMDAnCiAgICAgIC0gJ01JTklPX1BVQkxJQ19VUkw9JHtTRVJWSUNFX0ZRRE5fUlVTVEZTfScKICAgICAgLSAnTUlOSU9fU0VDVVJFPSR7TUlOSU9fU0VDVVJFOi1mYWxzZX0nCiAgICAgIC0gJ01JTklPX0FDQ0VTU0tFWT0ke1NFUlZJQ0VfVVNFUl9SVVNURlN9JwogICAgICAtICdNSU5JT19TRUNSRVRLRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1JVU1RGU30nCiAgICAgIC0gJ01JTklPX0JVQ0tFVD0ke01JTklPX0JVQ0tFVDotYWxleGFuZHJpZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVDotfScKICAgICAgLSAnU01UUF9NQUlMPSR7U01UUF9NQUlMOi19JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRDotfScKICAgIGRlcGVuZHNfb246CiAgICAgIG15c3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJ1c3RmczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWFsZXhhbmRyaWUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICctdScKICAgICAgICAtIHJvb3QKICAgICAgICAtICctcCR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHJldHJpZXM6IDUKICBydXN0ZnM6CiAgICBpbWFnZTogJ3J1c3Rmcy9ydXN0ZnM6MS4wLjAtYmV0YS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JVU1RGU185MDAwCiAgICAgIC0gJ1JVU1RGU19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX1JVU1RGU30nCiAgICAgIC0gJ1JVU1RGU19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9SVVNURlN9JwogICAgICAtICdSVVNURlNfQ09OU09MRV9FTkFCTEU9JHtSVVNURlNfQ09OU09MRV9FTkFCTEU6LWZhbHNlfScKICAgICAgLSAnUlVTVEZTX0xPR19MRVZFTD0ke1JVU1RGU19MT0dfTEVWRUw6LWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAncnVzdGZzLWRhdGE6L2RhdGEnCiAgICAgIC0gJ3J1c3Rmcy1sb2dzOi9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiBsb2NhbGhvc3QgOTAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"note-taking",
"markdown",
@ -1310,7 +1310,7 @@
"espocrm": {
"documentation": "https://docs.espocrm.com?utm_source=coolify.io",
"slogan": "EspoCRM is a free and open-source CRM platform.",
"compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==",
"compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX0ZRRE5fRVNQT0NSTX0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRhdGE6L3Zhci93d3cvaHRtbC9kYXRhJwogICAgICAtICdlc3BvY3JtLWN1c3RvbTovdmFyL3d3dy9odG1sL2N1c3RvbScKICAgICAgLSAnZXNwb2NybS1jdXN0b20tY2xpZW50Oi92YXIvd3d3L2h0bWwvY2xpZW50L2N1c3RvbScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybToxMCcKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0td2Vic29ja2V0OgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjEyLjMnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==",
"tags": [
"crm",
"self-hosted",

View file

@ -0,0 +1,47 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('admin search only shows users with paid subscriptions as active', function () {
config()->set('cache.default', 'array');
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(
fn () => InstanceSettings::query()->firstOrCreate(['id' => 0])
);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
$rootUser = User::find(0) ?? User::factory()->create(['id' => 0]);
$rootTeam->members()->syncWithoutDetaching([
$rootUser->id => ['role' => 'admin'],
]);
$inactiveUser = User::factory()->create(['email' => 'inactive@example.com']);
$inactiveTeam = Team::factory()->create();
$inactiveTeam->members()->attach($inactiveUser->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $inactiveTeam->id,
'stripe_subscription_id' => 'sub_stale',
'stripe_invoice_paid' => false,
]);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(AdminIndex::class)
->set([
'foundUsers' => collect(),
'search' => 'inactive@example.com',
])
->call('submitSearch')
->assertSee('No')
->assertDontSee('Yes');
});

View file

@ -0,0 +1,257 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('ssh-keys');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('build-secrets-api-test', ['*'])->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]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function buildSecretsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function buildSecretsGithubPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () {
test('updates the application setting', function () {
expect($this->application->settings->use_build_secrets)->toBeFalse();
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => true,
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue();
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => false,
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeFalse();
});
test('rejects non boolean values', function () {
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'use_build_secrets' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('use_build_secrets');
});
test('does not change the setting when omitted', function () {
$this->application->settings->update(['use_build_secrets' => true]);
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'name' => 'updated-name',
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue();
});
});
describe('POST /api/v1/applications/public use_build_secrets', function () {
test('creates an application with the requested build secrets setting', function (bool $useBuildSecrets) {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => $useBuildSecrets,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBe($useBuildSecrets);
})->with([
'enabled' => true,
'disabled' => false,
]);
test('rejects non boolean values', function () {
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => 'not-a-boolean',
'autogenerate_domain' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('use_build_secrets');
});
});
describe('other application creation endpoints use_build_secrets', function () {
test('creates a Dockerfile application with build secrets enabled', function () {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a Docker image application with build secrets enabled', function () {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'alpine',
'ports_exposes' => '80',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a private deploy key application with build secrets enabled', function () {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/private-deploy-key', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'private_key_uuid' => $privateKey->uuid,
'git_repository' => 'git@gitlab.com:coolify/build-secrets-test.git',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('creates a private GitHub App application with build secrets enabled', function () {
$privateKey = PrivateKey::create([
'name' => 'GitHub App Key',
'private_key' => buildSecretsGithubPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Build Secrets GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'build-secrets-client-id',
'client_secret' => 'build-secrets-client-secret',
'webhook_secret' => 'build-secrets-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'github-installation-token',
], 201),
'https://api.github.com/repos/coolify/build-secrets-test' => Http::response([
'id' => 123456,
]),
]);
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/private-github-app', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'github_app_uuid' => $githubApp->uuid,
'git_repository' => 'coolify/build-secrets-test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'use_build_secrets' => true,
'autogenerate_domain' => false,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
});
});

View file

@ -0,0 +1,183 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('application-settings-api-test', ['*'])->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]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function applicationSettingsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function recommendedApplicationSettingsPayload(): array
{
return [
'is_git_submodules_enabled' => false,
'is_git_lfs_enabled' => false,
'is_git_shallow_clone_enabled' => false,
'disable_build_cache' => true,
'inject_build_args_to_dockerfile' => false,
'include_source_commit_in_build' => true,
'is_env_sorting_enabled' => true,
'is_pr_deployments_public_enabled' => true,
'stop_grace_period' => 45,
'docker_images_to_keep' => 7,
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
'is_raw_compose_deployment_enabled' => true,
];
}
test('GET /api/v1/applications/{uuid} includes settings without internal metadata', function () {
$this->application->settings->update(recommendedApplicationSettingsPayload());
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk()
->assertJsonPath('settings.disable_build_cache', true)
->assertJsonPath('settings.stop_grace_period', 45)
->assertJsonMissingPath('settings.id')
->assertJsonMissingPath('settings.application_id')
->assertJsonMissingPath('settings.created_at')
->assertJsonMissingPath('settings.updated_at');
});
test('PATCH /api/v1/applications/{uuid} updates application settings', function () {
$this->application->update(['build_pack' => 'dockercompose']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", recommendedApplicationSettingsPayload())
->assertOk();
$settings = $this->application->fresh()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('application creation accepts application settings', function () {
Queue::fake();
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/application-settings-test',
'git_branch' => 'main',
'build_pack' => 'dockercompose',
'autogenerate_domain' => false,
], recommendedApplicationSettingsPayload()))
->assertCreated();
$settings = Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings;
foreach (recommendedApplicationSettingsPayload() as $field => $value) {
expect($settings->{$field})->toBe($value);
}
});
test('proxy settings regenerate managed labels', function () {
$this->application->settings->update([
'is_container_label_readonly_enabled' => true,
'is_gzip_enabled' => true,
'is_stripprefix_enabled' => true,
]);
$this->application->update(['custom_labels' => base64_encode('sentinel-label=true')]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_gzip_enabled' => false,
'is_stripprefix_enabled' => false,
])
->assertOk();
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
});
test('rejects invalid boolean application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'disable_build_cache' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('disable_build_cache');
});
test('validates stop grace period bounds', function (int $stopGracePeriod) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => $stopGracePeriod,
])
->assertUnprocessable()
->assertJsonValidationErrors('stop_grace_period');
})->with([
'below minimum' => 0,
'above maximum' => 3601,
]);
test('validates Docker image retention bounds', function (int $dockerImagesToKeep) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'docker_images_to_keep' => $dockerImagesToKeep,
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_images_to_keep');
})->with([
'below minimum' => -1,
'above maximum' => 101,
]);
test('stop grace period can be reset to null', function () {
$this->application->settings->update(['stop_grace_period' => 45]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'stop_grace_period' => null,
])
->assertOk();
expect($this->application->fresh()->settings->stop_grace_period)->toBeNull();
});
test('raw compose deployment can only be enabled for Docker Compose applications', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'is_raw_compose_deployment_enabled' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors('is_raw_compose_deployment_enabled');
});

View file

@ -5,8 +5,10 @@
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -15,7 +17,7 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@ -77,6 +79,56 @@ function backupHeaders(): array
}
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('rejects backup configurations for unsupported database types', function () {
$database = StandaloneRedis::create([
'uuid' => (string) Str::uuid(),
'name' => 'Redis DB',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertUnprocessable()
->assertJson([
'message' => 'Scheduled backups are not supported for this database type.',
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
test('defaults clickhouse backups to its configured database', function () {
$database = StandaloneClickhouse::create([
'uuid' => (string) Str::uuid(),
'name' => 'ClickHouse DB',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'image' => 'clickhouse/clickhouse-server:25.11',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertCreated();
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail();
expect($backup->databases_to_backup)->toBe('analytics')
->and($backup->database_type)->toBe(StandaloneClickhouse::class);
});
test('creates backup configuration with valid frequency', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [

View file

@ -102,6 +102,26 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string
});
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
test('creates a standalone destination', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'name' => 'API Standalone',
'network' => 'api-standalone-network',
]);
$response->assertCreated()
->assertJson([
'name' => 'API Standalone',
'network' => 'api-standalone-network',
'type' => 'standalone',
'server_uuid' => $this->server->uuid,
]);
expect(StandaloneDocker::where('server_id', $this->server->id)
->where('network', 'api-standalone-network')
->exists())->toBeTrue();
});
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);

View file

@ -11,6 +11,7 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
@ -117,6 +118,48 @@ function makePrivateKey(string $name, string $material, string $fingerprint, int
expect($this->applicationA->source_type)->not->toBe(Server::class);
});
test('changeSource dispatches configuration changed for an owned source', function () {
Http::fake([
'https://api.github.com/repos/*' => Http::response(['id' => 123]),
]);
$source = GithubApp::create([
'name' => 'own-github-app',
'team_id' => $this->teamA->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
]);
$this->applicationA->update(['git_repository' => 'coollabsio/coolify']);
Livewire::test(Source::class, ['application' => $this->applicationA->fresh()])
->call('changeSource', $source->id, GithubApp::class)
->assertDispatched('configurationChanged');
});
test('changeSource dispatches configuration changed when repository metadata lookup fails after persistence', function () {
Http::fake([
'https://api.github.com/repos/*' => Http::response(
['message' => 'Unavailable'],
503,
['X-RateLimit-Reset' => now()->addMinute()->timestamp],
),
]);
$source = GithubApp::create([
'name' => 'own-unavailable-github-app',
'team_id' => $this->teamA->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
]);
$this->applicationA->update(['git_repository' => 'coollabsio/coolify']);
Livewire::test(Source::class, ['application' => $this->applicationA->fresh()])
->call('changeSource', $source->id, GithubApp::class)
->assertDispatched('configurationChanged');
expect($this->applicationA->refresh()->source_id)->toBe($source->id);
});
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through

View file

@ -15,7 +15,10 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();

View file

@ -3,21 +3,28 @@
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
@ -30,7 +37,7 @@
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
@ -40,7 +47,7 @@
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->call('cloneTo', $this->destinationB->uuid)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
@ -48,12 +55,16 @@
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$secondDestination = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'second-destination',
]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
->call('cloneTo', $secondDestination->uuid)
->assertHasNoErrors('destination_id');
expect(Application::count())->toBe(2);
});
test('moveTo rejects environment belonging to another team', function () {

View file

@ -0,0 +1,154 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
});
test('it previews eligible unverified users without deleting them', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('Found 1 unverified user eligible for deletion.')
->expectsOutput('Dry run only. Use --yes to delete eligible users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it reports dry run progress', function () {
$users = User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users');
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Checking eligible users: 2/2');
$users->each(fn (User $user) => $this->assertModelExists($user));
});
test('it deletes eligible unverified users with the yes option', function () {
$user = User::factory()->unverified()->create([
'email' => 'unverified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 1 unverified user.')
->assertSuccessful();
$this->assertModelMissing($user);
});
test('it reports deletion progress', function () {
User::factory()->unverified()->count(2)->create();
$exitCode = Artisan::call('cloud:cleanup-unverified-users', ['--yes' => true]);
expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('Deleting eligible users: 2/2');
});
test('it keeps verified users', function () {
$user = User::factory()->create([
'email' => 'verified@example.com',
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with a Stripe subscription record', function () {
$user = User::factory()->unverified()->create([
'email' => 'subscribed@example.com',
]);
Subscription::create([
'team_id' => $user->teams()->firstOrFail()->id,
'stripe_invoice_paid' => false,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with defined resources', function () {
$user = User::factory()->unverified()->create([
'email' => 'resource-owner@example.com',
]);
$project = Project::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$environment = Environment::factory()->create([
'project_id' => $project->id,
]);
Application::factory()->create([
'environment_id' => $environment->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified users with servers', function () {
$user = User::factory()->unverified()->create([
'email' => 'server-owner@example.com',
]);
Server::factory()->create([
'team_id' => $user->teams()->firstOrFail()->id,
]);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it keeps unverified root team members', function () {
$user = User::factory()->unverified()->create([
'email' => 'root-member@example.com',
]);
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
]);
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->artisan('cloud:cleanup-unverified-users', ['--yes' => true])
->expectsOutput('Deleted 0 unverified users.')
->assertSuccessful();
$this->assertModelExists($user);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:cleanup-unverified-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
});

View file

@ -0,0 +1,101 @@
<?php
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('backups');
config()->set('constants.coolify.self_hosted', false);
});
test('it exports subscribed and unsubscribed verified users to separate files without tags', function () {
User::factory()->create([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
]);
$subscribedUser = User::factory()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
]);
Subscription::create([
'team_id' => $subscribedUser->teams()->firstOrFail()->id,
'stripe_invoice_paid' => true,
'stripe_subscription_id' => 'sub_active',
]);
User::factory()->create([
'name' => 'Grace',
'email' => 'grace@example.com',
]);
User::factory()->unverified()->create([
'name' => 'Unverified User',
'email' => 'unverified@example.com',
]);
Storage::disk('backups')->put('cloud-users.csv', 'old export');
$this->artisan('cloud:export-users')->assertSuccessful();
Storage::disk('backups')->assertExists([
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
Storage::disk('backups')->assertMissing('cloud-users.csv');
$readCsv = function (string $filename): array {
$output = fopen(Storage::disk('backups')->path($filename), 'rb');
$rows = [];
while (($row = fgetcsv($output, null, ',', '"', '')) !== false) {
$rows[] = $row;
}
fclose($output);
return $rows;
};
$header = [
'email',
'first_name',
'last_name',
'lifetime_value_currency',
'lifetime_value_amount',
'utm_campaign',
'utm_source',
'utm_medium',
'utm_content',
'utm_term',
'phone',
];
expect($readCsv('cloud-users-subscribed.csv'))->toBe([
$header,
['ada@example.com', 'Ada', 'Lovelace', '', '', '', '', '', '', '', ''],
])->and($readCsv('cloud-users-unsubscribed.csv'))->toBe([
$header,
['grace@example.com', 'Grace', '', '', '', '', '', '', '', '', ''],
]);
});
test('it only runs on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', true);
$this->artisan('cloud:export-users')
->expectsOutput('This command can only be run on Coolify Cloud.')
->assertFailed();
Storage::disk('backups')->assertMissing([
'cloud-users.csv',
'cloud-users-subscribed.csv',
'cloud-users-unsubscribed.csv',
]);
});

View file

@ -6,8 +6,10 @@
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -136,3 +138,49 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('creates a clickhouse backup for its configured database', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneClickhouse::create([
'name' => 'clickhouse-scheduled-backup',
'clickhouse_admin_user' => 'default',
'clickhouse_admin_password' => 'password',
'clickhouse_db' => 'analytics',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->call('submit')
->assertDispatched('refreshScheduledBackups');
$backup = ScheduledDatabaseBackup::firstOrFail();
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
->and($backup->databases_to_backup)->toBe('analytics');
});
it('rejects scheduled backups for unsupported database types', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneRedis::create([
'name' => 'redis-without-backups',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', 'daily')
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});

View file

@ -3,6 +3,7 @@
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -133,6 +134,109 @@
&& $request['user_data'] === '#cloud-config');
});
test('tracks the droplet with a placeholder IP when waiting for its IP fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
'links' => ['pages' => []],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'waiting-for-ip',
'status' => 'new',
'networks' => ['v4' => [], 'v6' => []],
],
], 202),
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
'message' => 'temporary provider failure',
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'name' => 'waiting-for-ip',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated();
$response->assertJsonFragment([
'digitalocean_droplet_id' => 987,
'ip' => Server::PLACEHOLDER_IP,
]);
$this->assertDatabaseHas('servers', [
'name' => 'waiting-for-ip',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'digitalocean_droplet_id' => 987,
'digitalocean_droplet_status' => 'new',
]);
});
test('deletes the droplet when local server persistence fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
'links' => ['pages' => []],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'persistence-fails',
'status' => 'active',
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
],
],
], 202),
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'name' => 'persistence-fails',
'private_key_uuid' => $this->privateKey->uuid,
]);
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$response->assertServerError();
$this->assertDatabaseMissing('servers', [
'digitalocean_droplet_id' => 987,
]);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,

View file

@ -1,10 +1,14 @@
<?php
use App\Livewire\Server\New\ByDigitalOcean;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@ -25,6 +29,136 @@
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->digitalOceanToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-token',
'name' => 'Test DigitalOcean Token',
]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
function submitDigitalOceanServer(): void
{
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid])
->assertSet('current_step', 2)
->set('server_name', 'test-do-server')
->set('selected_region', 'nyc1')
->set('selected_size', 's-1vcpu-1gb')
->set('selected_image', 'ubuntu-24-04-x64')
->set('private_key_id', test()->privateKey->id)
->call('submit')
->assertHasNoErrors();
}
it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => ['id' => 555, 'status' => 'new'],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500),
]);
submitDigitalOceanServer();
$this->assertDatabaseHas('servers', [
'name' => 'test-do-server',
'ip' => Server::PLACEHOLDER_IP,
'team_id' => $this->team->id,
'cloud_provider_token_id' => $this->digitalOceanToken->id,
'digitalocean_droplet_id' => '555',
'digitalocean_droplet_status' => 'new',
]);
});
it('updates the placeholder IP once the droplet reports one', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => ['id' => 555, 'status' => 'new'],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response([
'droplet' => [
'id' => 555,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.40'],
],
],
],
], 200),
]);
submitDigitalOceanServer();
$this->assertDatabaseHas('servers', [
'name' => 'test-do-server',
'ip' => '203.0.113.40',
'digitalocean_droplet_id' => '555',
'digitalocean_droplet_status' => 'active',
]);
});
it('deletes the droplet when local server persistence fails', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 555,
'status' => 'active',
'networks' => [
'v4' => [
['type' => 'public', 'ip_address' => '203.0.113.40'],
],
],
],
], 202),
'https://api.digitalocean.com/v2/droplets/555' => Http::response(null, 204),
]);
$eventDispatcher = Server::getEventDispatcher();
Server::setEventDispatcher(clone $eventDispatcher);
Server::created(function (): void {
throw new RuntimeException('local persistence failed');
});
try {
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => $this->digitalOceanToken->uuid])
->set('server_name', 'persistence-fails')
->set('selected_region', 'nyc1')
->set('selected_size', 's-1vcpu-1gb')
->set('selected_image', 'ubuntu-24-04-x64')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertDispatched('error', 'local persistence failed');
} finally {
Server::setEventDispatcher($eventDispatcher);
}
$this->assertDatabaseMissing('servers', [
'digitalocean_droplet_id' => 555,
]);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/555');
});
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {

View file

@ -21,6 +21,20 @@
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('keeps the environment variable delete button compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)->not->toContain('buttonFullWidth="true"');
});
it('aligns environment variable settings and actions in a responsive row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex w-full flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"')
->toContain('class="flex w-full justify-end gap-2 lg:w-auto lg:shrink-0"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));

View file

@ -27,9 +27,9 @@
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
$emails = [
'ke.vinmcfadden+one@btinternet.com',
'kevin.mcfadden+two@btinternet.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
'ke.vinmcfadden+one@gmail.com',
'kevin.mcfadden+two@gmail.com',
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com',
];
foreach ($emails as $index => $email) {
@ -42,7 +42,24 @@
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
->post('/forgot-password', [
'email' => 'k.evin.mcfadden+four@btinternet.com',
'email' => 'k.evin.mcfadden+four@gmail.com',
])
->assertTooManyRequests();
});
it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () {
$emails = [
'john.smith@example.com',
'johnsmith@example.com',
'johnsmith+one@example.com',
'johnsmith+two@example.com',
];
foreach ($emails as $index => $email) {
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)])
->post('/forgot-password', [
'email' => $email,
])
->assertSessionHasNoErrors();
}
});

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