diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index aab889479..c6f032013 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -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; } } } diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index 363166bcc..9181c537d 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -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; diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php index c83cbe660..ebee6e2f2 100644 --- a/app/Actions/Service/RestartServiceApplication.php +++ b/app/Actions/Service/RestartServiceApplication.php @@ -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; diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index cc1afbb96..724e1a254 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -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; diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Actions/Stripe/SyncStripeSubscriptions.php similarity index 61% rename from app/Jobs/SyncStripeSubscriptionsJob.php rename to app/Actions/Stripe/SyncStripeSubscriptions.php index 572d6e78c..977007961 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Actions/Stripe/SyncStripeSubscriptions.php @@ -1,30 +1,18 @@ 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); } } } diff --git a/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php new file mode 100644 index 000000000..bf63edce5 --- /dev/null +++ b/app/Console/Commands/Cloud/CleanupUnverifiedUsers.php @@ -0,0 +1,83 @@ +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'); + }); + } +} diff --git a/app/Console/Commands/Cloud/ExportUsers.php b/app/Console/Commands/Cloud/ExportUsers.php new file mode 100644 index 000000000..5e383c4ce --- /dev/null +++ b/app/Console/Commands/Cloud/ExportUsers.php @@ -0,0 +1,127 @@ +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 $fields + */ + private function writeCsvRow($output, array $fields): void + { + if (fputcsv($output, $fields, ',', '"', '') === false) { + throw new RuntimeException('Unable to write the CSV file.'); + } + } +} diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index 46f6b4edd..cedacfbeb 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -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(); } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0f853642d..468589a1d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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)) { diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3761effd7..f2c7f2226 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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)) { diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index f58e2ee71..a745ea5d2 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -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(); diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php index fc51e7c01..5bd9d2392 100644 --- a/app/Http/Controllers/Api/DigitalOceanController.php +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -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); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 8c82fa2ad..b3685daa4 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -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; } } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index fa0017f87..4343971ed 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -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([ diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index a144b3ea6..e1df34903 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -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(); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php new file mode 100644 index 000000000..480ff4e55 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -0,0 +1,452 @@ +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]; + } +} diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index aa9afe0ad..9c074c9a1 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -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)) { diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 7aaba568e..51fad6a0b 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -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); diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php index 5d3bed457..e74cba554 100644 --- a/app/Jobs/CleanupOrphanedPreviewContainersJob.php +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -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([ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index e9e1f9105..f25158d34 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -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) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 62e98934e..fbf5cd154 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -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'; diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e7a21949c..46bc89d42 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -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 diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php new file mode 100644 index 000000000..cd2e51df7 --- /dev/null +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -0,0 +1,59 @@ +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(), + ]); + } + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 60adf8c18..f86686df7 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -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 { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 9532282cc..67c222c24 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -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; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index f62f8bfdd..bf84f385d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -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) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 289c8eeb0..3e5a51944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -489,6 +489,7 @@ public function instantSave() if ($this->isContainerLabelReadonlyEnabled) { $this->resetDefaultLabels(false); } + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 3ee5919fe..fe6a6397a 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -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}"); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 94d627e67..661578fb3 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -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); } diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 0a6e3d8ec..fff2b7fbf 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -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(); diff --git a/app/Livewire/Project/Database/Backup/Index.php b/app/Livewire/Project/Database/Backup/Index.php index 2df32ec7b..71380754f 100644 --- a/app/Livewire/Project/Database/Backup/Index.php +++ b/app/Livewire/Project/Database/Backup/Index.php @@ -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, diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7384adcff..49065711b 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -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); diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 55ed8941c..2f9264730 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -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) { diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 68ee0d055..ab6063e09 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -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([ diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 35e9b186e..479c2a1f5 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -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(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index d5b4bbef8..fb3c0b2c3 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -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(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 4fddd744b..fdae52f7c 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -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(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 34601f5dd..08047fc79 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -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(); } } diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 3328c5db3..24f21b4cb 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -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([ diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index e0b45eea0..19ffad55c 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -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) { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 4f3e659da..94fb4b4eb 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -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(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index b45d9aba3..bac4546ce 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -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); } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 5fa62b04e..cb60a3f39 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -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); } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 02171af8d..ba6a6e03d 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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(); diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 7b0151851..cf27f0f08 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -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'); diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 5ef88acee..1059c6713 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -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'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 9e6bc3cf2..4246d5836 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -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'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 15af859c9..1090e9892 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -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.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index d46e4366b..732142b0d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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'), ] )] diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index ef09c0c48..91c38b879 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -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 = [ diff --git a/app/Models/Server.php b/app/Models/Server.php index 4bf57207f..3acaf3507 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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 $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()); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index e96aab4a3..0453dc793 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -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', diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 69801f985..603d11a7f 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -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', ]; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 9db5f21b7..7ca45cc3b 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -370,6 +370,6 @@ public function scheduledBackups() public function isBackupSolutionAvailable() { - return false; + return true; } } diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index c1dd4bf67..604a245fc 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -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([ diff --git a/app/Models/Team.php b/app/Models/Team.php index d01bfc45c..665251653 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -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, diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index 88cb15115..8af168ab2 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -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. */ diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 365708758..aeb40364a 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -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([ diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index e9707edbe..94c87b13c 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -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> + */ + 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 $previousSnapshot * @param array $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; } diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index c8292e864..da1af942f 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -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()) { diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 0e335d3e0..347b7e3fb 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -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()) { diff --git a/app/Support/ClickhouseBackupCommand.php b/app/Support/ClickhouseBackupCommand.php new file mode 100644 index 000000000..69e93d5c3 --- /dev/null +++ b/app/Support/ClickhouseBackupCommand.php @@ -0,0 +1,37 @@ + */ + 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); + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index e430e7364..e314ead82 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -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'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index b7e4af7ab..339a0bcf7 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -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(); diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php index a0b8ba67f..a4a311e04 100644 --- a/bootstrap/helpers/email.php +++ b/bootstrap/helpers/email.php @@ -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; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 10de1f86f..8900c0cd3 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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()) { diff --git a/composer.lock b/composer.lock index e4a343887..25cce68ff 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php index 500fa1fcf..98b0c73c2 100644 --- a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -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) diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 337e79b2b..459ed83e8 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -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 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 46fde62e6..cdea56467 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -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 diff --git a/openapi.json b/openapi.json index 7ffe9ecca..093b61010 100644 --- a/openapi.json +++ b/openapi.json @@ -380,6 +380,68 @@ "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." @@ -841,6 +903,68 @@ "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." @@ -1302,6 +1426,68 @@ "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." @@ -1668,6 +1854,68 @@ "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." @@ -2014,6 +2262,68 @@ "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." @@ -2596,6 +2906,67 @@ "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." @@ -2612,10 +2983,6 @@ "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." } }, "type": "object" @@ -8059,6 +8426,260 @@ ] } }, + "\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations", + "description": "List all Docker network destinations for the authenticated team.", + "operationId": "list-destinations", + "responses": { + "200": { + "description": "Destinations for the authenticated team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{server_uuid}\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations by server", + "description": "List Docker network destinations attached to a server owned by the authenticated team.", + "operationId": "list-server-destinations", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destinations attached to the server.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Destinations" + ], + "summary": "Create destination", + "description": "Create a Docker network destination on a server owned by the authenticated team.", + "operationId": "create-server-destination", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "network" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "network": { + "type": "string", + "maxLength": 255, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Destination created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "A destination with this network already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/destinations\/{uuid}": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "Get destination", + "description": "Get a Docker network destination by UUID.", + "operationId": "get-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination details.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Destinations" + ], + "summary": "Delete destination", + "description": "Delete an unused Docker network destination.", + "operationId": "delete-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Destination has attached resources." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/digitalocean\/regions": { "get": { "tags": [ @@ -12110,6 +12731,76 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Get service application logs", + "description": "Get Docker logs for a single compose service container.", + "operationId": "post-service-application-logs-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/start": { @@ -12191,6 +12882,84 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "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.", + "operationId": "post-start-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/restart": { @@ -12252,6 +13021,66 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Restart service application container", + "description": "Restarts a single compose service container.", + "operationId": "post-restart-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/applications\/{app_uuid}\/stop": { @@ -12313,6 +13142,550 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Service applications" + ], + "summary": "Stop service application container", + "description": "Stops a single compose service container.", + "operationId": "post-stop-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "List service databases", + "description": "List compose databases for a single service.", + "operationId": "list-service-databases-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service databases.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database", + "description": "Get a compose database by service UUID and database UUID.", + "operationId": "get-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service database.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Service databases" + ], + "summary": "Update service database", + "description": "Update mutable fields for a compose service database.", + "operationId": "patch-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "properties": { + "human_name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": "string" + }, + "exclude_from_status": { + "type": "boolean" + }, + "is_log_drain_enabled": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "public_port": { + "type": [ + "integer", + "null" + ], + "maximum": 65535, + "minimum": 1 + }, + "public_port_timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Updated service database.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/logs": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database logs", + "description": "Get Docker logs for a compose database container.", + "operationId": "get-service-database-logs-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/start": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Start or redeploy service database container", + "description": "Run docker compose up for a single compose database.", + "operationId": "start-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/restart": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Restart service database container", + "description": "Restart a compose database container.", + "operationId": "restart-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/stop": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Stop service database container", + "description": "Stop a compose database container.", + "operationId": "stop-service-database-by-service-and-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services": { @@ -12717,26 +14090,6 @@ "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." @@ -14892,6 +16245,9 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "settings": { + "$ref": "#\/components\/schemas\/ApplicationSetting" } }, "type": "object" @@ -14987,6 +16343,123 @@ }, "type": "object" }, + "ApplicationSetting": { + "description": "Application settings.", + "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 + } + }, + "type": "object" + }, "Environment": { "description": "Environment model", "properties": { @@ -15514,6 +16987,39 @@ }, "type": "object" }, + "Destination": { + "description": "A Docker network destination attached to a server.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + }, + "server_uuid": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, "Tag": { "description": "Tag model", "properties": { @@ -15754,6 +17260,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "Destinations", + "description": "Destinations" + }, { "name": "DigitalOcean", "description": "DigitalOcean" @@ -15790,6 +17300,10 @@ "name": "Service applications", "description": "Service applications" }, + { + "name": "Service databases", + "description": "Service databases" + }, { "name": "Services", "description": "Services" diff --git a/openapi.yaml b/openapi.yaml index 3b2f5c4d5..061940991 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -268,6 +268,54 @@ paths: 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.' @@ -562,6 +610,54 @@ paths: 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.' @@ -856,6 +952,54 @@ paths: 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.' @@ -1091,6 +1235,54 @@ paths: 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.' @@ -1312,6 +1504,54 @@ paths: 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.' @@ -1688,6 +1928,53 @@ paths: 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.' @@ -1701,9 +1988,6 @@ paths: 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.' type: object responses: '200': @@ -5232,6 +5516,170 @@ paths: security: - bearerAuth: [] + /destinations: + get: + tags: + - Destinations + summary: 'List destinations' + description: 'List all Docker network destinations for the authenticated team.' + operationId: list-destinations + responses: + '200': + description: 'Destinations for the authenticated team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + security: + - + bearerAuth: [] + '/servers/{server_uuid}/destinations': + get: + tags: + - Destinations + summary: 'List destinations by server' + description: 'List Docker network destinations attached to a server owned by the authenticated team.' + operationId: list-server-destinations + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destinations attached to the server.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Destinations + summary: 'Create destination' + description: 'Create a Docker network destination on a server owned by the authenticated team.' + operationId: create-server-destination + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - network + properties: + name: + type: string + maxLength: 255 + network: + type: string + maxLength: 255 + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + type: + type: string + enum: [standalone, swarm] + type: object + responses: + '201': + description: 'Destination created.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'A destination with this network already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/destinations/{uuid}': + get: + tags: + - Destinations + summary: 'Get destination' + description: 'Get a Docker network destination by UUID.' + operationId: get-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination details.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - Destinations + summary: 'Delete destination' + description: 'Delete an unused Docker network destination.' + operationId: delete-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: Deleted. } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Destination has attached resources.' + security: + - + bearerAuth: [] /digitalocean/regions: get: tags: @@ -7725,6 +8173,53 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Get service application logs' + description: 'Get Docker logs for a single compose service container.' + operationId: post-service-application-logs-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + - + name: lines + in: query + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/start': get: tags: @@ -7781,6 +8276,59 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + 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.' + operationId: post-start-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + - + name: force + in: query + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/restart': get: tags: @@ -7821,6 +8369,45 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Restart service application container' + description: 'Restarts a single compose service container.' + operationId: post-restart-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] '/services/{uuid}/applications/{app_uuid}/stop': get: tags: @@ -7861,6 +8448,359 @@ paths: security: - bearerAuth: [] + post: + tags: + - 'Service applications' + summary: 'Stop service application container' + description: 'Stops a single compose service container.' + operationId: post-stop-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: app_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases': + get: + tags: + - 'Service databases' + summary: 'List service databases' + description: 'List compose databases for a single service.' + operationId: list-service-databases-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service databases.' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}': + get: + tags: + - 'Service databases' + summary: 'Get service database' + description: 'Get a compose database by service UUID and database UUID.' + operationId: get-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service database.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Service databases' + summary: 'Update service database' + description: 'Update mutable fields for a compose service database.' + operationId: patch-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + human_name: + type: [string, 'null'] + description: + type: [string, 'null'] + image: + type: string + exclude_from_status: + type: boolean + is_log_drain_enabled: + type: boolean + is_public: + type: boolean + public_port: + type: [integer, 'null'] + maximum: 65535 + minimum: 1 + public_port_timeout: + type: [integer, 'null'] + minimum: 1 + type: object + additionalProperties: false + responses: + '200': + description: 'Updated service database.' + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/logs': + get: + tags: + - 'Service databases' + summary: 'Get service database logs' + description: 'Get Docker logs for a compose database container.' + operationId: get-service-database-logs-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + - + name: lines + in: query + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/start': + post: + tags: + - 'Service databases' + summary: 'Start or redeploy service database container' + description: 'Run docker compose up for a single compose database.' + operationId: start-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + - + name: force + in: query + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/restart': + post: + tags: + - 'Service databases' + summary: 'Restart service database container' + description: 'Restart a compose database container.' + operationId: restart-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/stop': + post: + tags: + - 'Service databases' + summary: 'Stop service database container' + description: 'Stop a compose database container.' + operationId: stop-service-database-by-service-and-database-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: database_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] /services: get: tags: @@ -8102,21 +9042,6 @@ paths: 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.' @@ -9519,6 +10444,8 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + settings: + $ref: '#/components/schemas/ApplicationSetting' type: object ApplicationDeploymentQueue: description: 'Project model' @@ -9582,6 +10509,86 @@ components: commit_message: type: string type: object + ApplicationSetting: + description: 'Application settings.' + 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 + type: object Environment: description: 'Environment model' properties: @@ -9958,6 +10965,29 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Destination: + description: 'A Docker network destination attached to a server.' + properties: + uuid: + type: string + name: + type: string + network: + type: string + type: + type: string + enum: + - standalone + - swarm + server_uuid: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + type: object Tag: description: 'Tag model' properties: @@ -10117,6 +11147,9 @@ tags: - name: Deployments description: Deployments + - + name: Destinations + description: Destinations - name: DigitalOcean description: DigitalOcean @@ -10144,6 +11177,9 @@ tags: - name: 'Service applications' description: 'Service applications' + - + name: 'Service databases' + description: 'Service databases' - name: Services description: Services diff --git a/resources/views/livewire/admin/index.blade.php b/resources/views/livewire/admin/index.blade.php index acba3acce..8abb92413 100644 --- a/resources/views/livewire/admin/index.blade.php +++ b/resources/views/livewire/admin/index.blade.php @@ -22,7 +22,7 @@
{{ $user->name }}
{{ $user->email }}
Active: - {{ $user->teams()->whereRelation('subscription', 'stripe_subscription_id', '!=', null)->exists() ? 'Yes' : 'No' }} + {{ $user->teams()->whereRelation('subscription', 'stripe_invoice_paid', true)->exists() ? 'Yes' : 'No' }}
diff --git a/resources/views/livewire/project/clone-me.blade.php b/resources/views/livewire/project/clone-me.blade.php index 3c7f874ce..f150b5525 100644 --- a/resources/views/livewire/project/clone-me.blade.php +++ b/resources/views/livewire/project/clone-me.blade.php @@ -25,13 +25,13 @@ @foreach ($servers->sortBy('id') as $server) @foreach ($server->destinations() as $destination) + wire:click="selectServer('{{ $server->id }}', '{{ $destination->uuid }}')"> uuid }}' ? 'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'"> {{ $server->name }} uuid }}' ? 'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'"> {{ $destination->name }} diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 4f810d755..515241364 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -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') + @endif
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index ca4fcf1ea..66af4895f 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -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 @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()) Backups diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 2d3750a8a..83a9198af 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -433,28 +433,40 @@ function searchResources() { server. Go to servers page
- @else - @forelse($servers as $server) -
-
-
- {{ $server->name }} -
-
- {{ $server->description }} -
+ @endif + @forelse($servers as $server) +
+
+
+ {{ $server->name }} +
+
+ {{ $server->description }}
- @empty +
+ @empty + @if ($buildServers?->isEmpty() && ! $onlyBuildServerAvailable)
-
No validated & reachable servers found. Go to servers page
- @endforelse - @endif + @endif + @endforelse + @foreach($buildServers ?? [] as $buildServer) +
+
+
{{ $buildServer->name }}
+
+ This server is configured as a build server and cannot host resources. + Change server settings +
+
+
+ @endforeach
@endif @if ($current_step === 'destinations') diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index c602ba4af..76cfcead6 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -217,8 +217,9 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" />
@endcan @can('update', $this->env) -
-
+
+
+
@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 +
+
- @if (!$isMagicVariable) -
+
@if ($isDisabled) Update Lock @@ -284,14 +286,14 @@ class="input italic !text-neutral-500 dark:!text-neutral-500" /> Lock @endif
@elseif ($type === 'service') -
+
Lock
@endif diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index 0c3c8885c..769757dc1 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -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 @@ + @foreach ($buildServers as $buildServer) + + @endforeach
@@ -84,8 +88,8 @@