diff --git a/README.md b/README.md index b387d87e8..ee4028d6a 100644 --- a/README.md +++ b/README.md @@ -57,106 +57,95 @@ Thank you so much! ### Huge Sponsors -* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality -* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API -* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code -* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control +* [Context.dev](https://www.context.dev/) - Web scraping API for AI agents +* [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API. +* [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality +* [ScreenshotOne](https://screenshotone.com) - Screenshot API for devs +* [PrivateAlps](https://privatealps.net) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control +* [Seibert Group](https://seibert.link/coolifysoftware) - Boost productivity company-wide with AI agents like Claude Code +* [Contabo](https://contabo.com/en/coolify-vps/) - Cloud VPS & dedicated servers at unbeatable prices ### Big Sponsors -* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! -* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services -* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions -* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner -* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers -* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale -* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half -* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor -* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform -* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design -* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. -* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform -* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions -* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer +* [Cloudways](https://www.cloudways.com/en/?id=2125302) - Managed cloud hosting platform by DigitalOcean +* [ByteBase](https://www.bytebase.com) - Database CI/CD and Security at Scale +* [Ramnode](https://ramnode.com/) - High Performance Cloud VPS Hosting +* [23M](https://23m.com) - Your experts for high-availability hosting solutions! +* [Macarne](https://macarne.com) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions -* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions -* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency -* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions -* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers -* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting -* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity -* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity -* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions -* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang -* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting -* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers -* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase -* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data -* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers -* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform -* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform -* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform -* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions - +* [Logto](https://logto.io) - The better identity infrastructure for developers +* [Supadata](https://supadata.ai/) - Scrape YouTube, web, and files. Get AI-ready, clean data for your next project. +* [Tolgee](https://tolgee.io) - The open source localization platform +* [Best Consultant](https://bc.direct) - Your trusted technology consulting partner +* [ArcJet](https://arcjet.com) - Advanced web security and performance solutions +* [SupaGuide](https://supa.guide) - Your comprehensive guide to Supabase +* [CodeRabbit](https://coderabbit.ai) - Cut Code Review Time & Bugs in Half +* [Convex](https://convex.link/coolify.io) - Convex is the open-source reactive database for web app developers. +* [GoldenVM](https://billing.goldenvm.com) - Premium virtual machine hosting solutions +* [Comit International](https://comit.international) - New York Times award–winning contractor! +* [Compai](https://www.trycomp.ai) - The open source compliance automation platform that does everything you need to get compliant, fast. Open source alternative to Drata & Vanta. +* [Tigris](https://www.tigrisdata.com) - Modern S3 Alternative +* [Blacksmith](https://blacksmith.sh) - Infrastructure automation platform +* [JobsCollider](https://jobscollider.com/remote-jobs) - 30,000+ remote jobs for developers +* [Darweb](https://darweb.nl/?ref=coolify.io&utm_source=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions for eCommerce. +* [Hostinger](https://www.hostinger.com/vps/coolify-hosting) - Web hosting and VPS solutions +* [Mobb](https://vibe.mobb.ai/) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [Ubicloud](https://www.ubicloud.com) - Open source cloud infrastructure platform +* [PFGLabs](https://pfglabs.com) - Build Real Projects with Golang +* [JuxtDigital](https://juxtdigital.com) - Digital PR & AI Authority Building Agency +* [SaasyKit](https://saasykit.com) - Complete SaaS starter kit for developers +* [American Cloud](https://americancloud.com) - US-based cloud infrastructure services +* [LiquidWeb](https://liquidweb.com) - Premium managed hosting solutions +* [Greptile](https://www.greptile.com) - The AI Code Reviewer +* [VPSDime](https://vpsdime.com/) - Cheap VPS Hosting - 4GB for $5/month +* [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. +* [ISHosting](https://ishosting.com/) - Hosting and VPS solutions +* [PetroSky Cloud](https://petrosky.io) - Open source cloud deployment solutions +* [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting ### Small Sponsors -OpenElements -XamanApp -UXWizz -Evercam -Imre Ujlaki -jyc.dev -TheRealJP -360Creators -NiftyCo -Dry Software -Lightspeed.run -LinkDr -Gravity Wiz -BitLaunch -Best for Android -Ilias Ism -Formbricks -Server Searcher -Reshot -Cirun -Typebot -Creating Coding Careers -Internet Garden -Web3 Jobs -Codext -Michael Mazurczak -Fider -Flint -Paweł Pierścionek -RunPod -DartNode -Tyler Whitesides -Aquarela -Crypto Jobs List -Alfred Nutile -Startup Fame -Younes Barrad -Jonas Jaeger -Pixel Infinito -Corentin Clichy -Thompson Edolo -Devhuset -Arvensis Systems -Niklas Lausch -Cap-go -InterviewPal -Transcript LOL +Movavi +ABXY +LaunchFast Boilerplates +Vanaways +Netrouting +MindEd Tech YouStable -MindedTech -NetRouting -ParsecPH - +Transcript LOL +Autom +HuntAPI +ULTRASERVERS +VibeTone +Piloterr +Alexey Panteleev +SummYT - YouTube Summarizer +OpenElements +Xaman +Monadical +Magic as a Service +FiveManage +Crypto Jobs List +SerpAPI +typebot +360Creators +Cap-go +Cirun +Puls Digital Group +Jonathan Pereira +Internet Garden +Evercam +Web3 Jobs +LinkDr +Arvensis Systems +Reshot +RunPod +Gravity Wiz +UXWizz +Codext +InterviewPal +Decidable +Host Havoc ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index cddf66389..44a03c17d 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,7 +4,9 @@ namespace App\Actions\Fortify; use App\Models\Team; use App\Models\User; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; @@ -12,6 +14,16 @@ use Laravel\Fortify\Contracts\CreatesNewUsers; class CreateNewUser implements CreatesNewUsers { + private const REGISTRATION_IP_MAX_ATTEMPTS = 3; + + private const REGISTRATION_IP_DECAY_SECONDS = 600; + + private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3; + + private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; + + public function __construct(private readonly Request $request) {} + /** * Validate and create a newly registered user. * @@ -23,6 +35,9 @@ class CreateNewUser implements CreatesNewUsers if (! $settings->is_registration_enabled) { abort(403); } + + $this->ensureRegistrationIsNotRateLimited($input); + Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => [ @@ -72,4 +87,42 @@ class CreateNewUser implements CreatesNewUsers return $user; } + + /** + * @param array $input + */ + private function ensureRegistrationIsNotRateLimited(array $input): void + { + $keys = [ + [ + 'key' => 'registration:ip:'.sha1($this->realIp()), + 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, + ], + ]; + + $emailIdentity = normalize_email_identity($input['email'] ?? null); + if ($emailIdentity !== null) { + $keys[] = [ + 'key' => 'registration:email-identity:'.sha1($emailIdentity), + 'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS, + ]; + } + + foreach ($keys as $limit) { + if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) { + abort(429, 'Too many registration attempts. Please try again later.'); + } + } + + foreach ($keys as $limit) { + RateLimiter::hit($limit['key'], $limit['decay']); + } + } + + private function realIp(): string + { + return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); + } } diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index f0e57b2cc..aab889479 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -6,14 +6,16 @@ use App\Models\CloudProviderToken; use App\Models\Server; use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; +use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) { $server = Server::withTrashed()->find($serverId); @@ -26,6 +28,24 @@ class DeleteServer ); } + if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) { + $this->deleteFromVultrById( + $vultrInstanceId ?? $server->vultr_instance_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + if ($deleteFromDigitalOcean && ($digitalOceanDropletId || ($server && $server->digitalocean_droplet_id))) { + $this->deleteFromDigitalOceanById( + $digitalOceanDropletId ?? $server->digitalocean_droplet_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + // If server is already deleted from Coolify, skip this part if (! $server) { return; // Server already force deleted from Coolify @@ -48,7 +68,10 @@ class DeleteServer $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); } if (! $token) { @@ -79,4 +102,90 @@ class DeleteServer $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); } } + + private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); + } + + if (! $token) { + logger()->debug('No Vultr token found for team, skipping Vultr deletion', [ + 'team_id' => $teamId, + 'vultr_instance_id' => $vultrInstanceId, + ]); + + return; + } + + $vultrService = new VultrService($token->token); + $vultrService->deleteInstance($vultrInstanceId); + + logger()->debug('Deleted server from Vultr', [ + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete server from Vultr', [ + 'error' => $e->getMessage(), + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + } + } + + private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + logger()->debug('No DigitalOcean token found for team, skipping droplet deletion', [ + 'team_id' => $teamId, + 'digitalocean_droplet_id' => $digitalOceanDropletId, + ]); + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $digitalOceanService->deleteDroplet($digitalOceanDropletId); + + logger()->debug('Deleted droplet from DigitalOcean', [ + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete droplet from DigitalOcean', [ + 'error' => $e->getMessage(), + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 22c48aa89..378998fe7 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -28,6 +28,32 @@ class ValidateServer $server->update([ 'validation_logs' => null, ]); + if ($server->vultr_instance_id) { + $status = $server->refreshVultrState(); + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + + if ($server->digitalocean_droplet_id) { + $status = $server->refreshDigitalOceanState(); + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 000000000..363166bcc --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,65 @@ +service; + $composeServiceName = $serviceApplication->name; + + $service->parse(); + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $workdir = $service->workdir(); + $composeFile = "{$workdir}/docker-compose.yml"; + $safeWorkdir = escapeshellarg($workdir); + $safeComposeFile = escapeshellarg($composeFile); + $safeProjectName = escapeshellarg($service->uuid); + $safeComposeServiceName = escapeshellarg($composeServiceName); + + $commands = collect([ + 'echo '.escapeshellarg("Saved configuration files to {$workdir}."), + 'touch '.escapeshellarg("{$workdir}/.env"), + ]); + + if ($pullLatestImages) { + $commands->push('echo Pulling image for service.'); + $commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}"); + } + + if ($service->networks()->count() > 0) { + $commands->push('echo Creating Docker network.'); + $commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}"); + } + + $upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps"; + if ($forceRebuild) { + $upCommand .= ' --build'; + } + $upCommand .= " {$safeComposeServiceName}"; + $commands->push('echo Starting service container.'); + $commands->push($upCommand); + + $commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true"); + + if (data_get($service, 'connect_to_docker_network')) { + $network = escapeshellarg($service->destination->network); + $containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true"); + } + + return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 000000000..c83cbe660 --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 000000000..cc1afbb96 --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 000000000..7bc04dfdb --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +boolean('force_domain_override'); + + if (array_key_exists('url', $payload)) { + $urlRaw = $payload['url']; + if ($urlRaw !== null && ! is_string($urlRaw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['url' => 'The url must be a string.'], + ], 422); + } + + $parsed = ServiceComposeUrl::validateUrlString( + is_string($urlRaw) ? $urlRaw : null, + $forceDomainOverride + ); + + if (count($parsed['errors']) > 0) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $parsed['errors'], + ], 422); + } + + if ($parsed['normalized'] !== null) { + $containerUrls = str($parsed['normalized']) + ->explode(',') + ->map(fn ($url) => str(trim((string) $url))->lower()); + + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + + $serviceApplication->fqdn = $parsed['normalized']; + } + + if (array_key_exists('human_name', $payload)) { + $serviceApplication->human_name = $payload['human_name']; + } + + if (array_key_exists('description', $payload)) { + $serviceApplication->description = $payload['description']; + } + + if (array_key_exists('image', $payload)) { + $serviceApplication->image = $payload['image']; + } + + if (array_key_exists('exclude_from_status', $payload)) { + $serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_gzip_enabled', $payload)) { + $serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_stripprefix_enabled', $payload)) { + $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_log_drain_enabled', $payload)) { + $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); + $server = $serviceApplication->service->destination->server; + if ($enabled && ! $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); + } + $serviceApplication->is_log_drain_enabled = $enabled; + } + + $serviceApplication->save(); + $serviceApplication->refresh(); + + updateCompose($serviceApplication); + + return null; + } +} diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 3f3e213fd..55acf3828 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -5,9 +5,12 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Pool; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\select; class SyncBunny extends Command { @@ -16,7 +19,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--bunny}'; /** * The console command description. @@ -25,15 +28,234 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + protected function removeTemporaryDirectory(string $tmpDir): void + { + $temporaryRoot = realpath(sys_get_temp_dir()); + $temporaryDirectory = realpath($tmpDir); + + if ($temporaryRoot === false || $temporaryDirectory === false) { + return; + } + + $expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-'; + if (! str_starts_with($temporaryDirectory, $expectedPrefix)) { + return; + } + + File::deleteDirectory($temporaryDirectory); + } + + /** + * Fetch GitHub releases and sync to GitHub repository + */ + private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + + $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-'); + if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) { + $this->error('Failed to create temporary releases.json.'); + + return false; + } + + $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + + try { + return $this->syncFilesToGitHubRepo($files, $nightly); + } finally { + @unlink($releasesFile); + } + } catch (\Throwable $e) { + $this->error('Error syncing releases: '.$e->getMessage()); + + return false; + } + } + + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coollabs-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $changedFiles = []; + exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check changed files: '.implode("\n", $changedFiles)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $changedFiles = array_values(array_filter($changedFiles)); + if (empty($changedFiles)) { + $this->info('All files are already up to date. No changes to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $changedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + $this->removeTemporaryDirectory($tmpDir); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($changedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ public function handle() { $that = $this; - $only_template = $this->option('templates'); - $only_version = $this->option('release'); - $nightly = $this->option('nightly'); + $only_bunny = $this->option('bunny'); + $nightly = select( + label: 'Which environment would you like to sync?', + options: [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ], + default: 'production', + ) === 'nightly'; $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; $bunny_cdn_storage_name = 'coolcdn'; @@ -55,6 +277,7 @@ class SyncBunny extends Command $upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh"; $production_env_location = "$parent_dir/.env.production"; + $service_template_location = "$parent_dir/templates/$service_template"; $versions_location = "$parent_dir/$versions"; PendingRequest::macro('storage', function ($fileName) use ($that) { @@ -93,7 +316,7 @@ class SyncBunny extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if ($only_bunny) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); @@ -108,7 +331,7 @@ class SyncBunny extends Command $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", ]; - $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time(); @mkdir($diffTmpDir, 0755, true); $hasChanges = false; @@ -151,7 +374,7 @@ class SyncBunny extends Command } } - exec('rm -rf '.escapeshellarg($diffTmpDir)); + $this->removeTemporaryDirectory($diffTmpDir); if (! $hasChanges) { $this->newLine(); @@ -167,49 +390,55 @@ class SyncBunny extends Command return; } } - if ($only_template) { - $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); - $confirmed = confirm('Are you sure you want to sync?'); - if (! $confirmed) { - return; - } - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"), - ]); - $this->info('Service template uploaded & purged...'); + if (! $only_bunny) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository."); - return; - } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); + $files = [ + $versions_location => 'json/coolify/nightly/versions.json', + $compose_file_location => 'json/coolify/nightly/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml', + $production_env_location => 'json/coolify/nightly/.env.production', + $install_script_location => 'json/coolify/nightly/install.sh', + $upgrade_script_location => 'json/coolify/nightly/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh', + $service_template_location => 'json/coolify/nightly/service-templates-latest.json', + ]; } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); - } - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("Version: {$actual_version}"); - $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN'); - $this->newLine(); - - $confirmed = confirm('Are you sure you want to proceed?'); - if (! $confirmed) { - return; + $files = [ + $versions_location => 'json/coolify/versions.json', + $compose_file_location => 'json/coolify/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml', + $production_env_location => 'json/coolify/.env.production', + $install_script_location => 'json/coolify/install.sh', + $upgrade_script_location => 'json/coolify/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh', + $service_template_location => 'json/coolify/service-templates-latest.json', + ]; } - $this->info('Syncing versions.json to BunnyCDN...'); - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), - ]); - $this->info('✓ versions.json uploaded & purged to BunnyCDN'); - $this->newLine(); + $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + $options = [$releasesTarget, ...array_values($files)]; + $selectedFiles = multiselect( + label: 'Which files would you like to sync?', + options: $options, + default: $options, + required: true, + scroll: count($options), + ); - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: ✓ Complete'); + $includeReleases = in_array($releasesTarget, $selectedFiles, true); + $files = array_filter( + $files, + fn (string $targetPath) => in_array($targetPath, $selectedFiles, true), + ); + + if ($includeReleases) { + $this->syncReleasesToGitHubRepo($files, $nightly); + } else { + $this->syncFilesToGitHubRepo($files, $nightly); + } return; } @@ -231,10 +460,6 @@ class SyncBunny extends Command $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); $this->info('All files uploaded & purged to BunnyCDN.'); - $this->newLine(); - - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: Complete'); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 790fdf200..0f853642d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -33,6 +33,27 @@ use Symfony\Component\Yaml\Yaml; class ApplicationsController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Application not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($application) { $application->makeHidden([ @@ -41,8 +62,8 @@ class ApplicationsController extends Controller 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $application->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $application->makeVisible([ 'custom_labels', 'dockerfile', 'docker_compose', @@ -51,10 +72,14 @@ class ApplicationsController extends Controller 'manual_webhook_secret_gitea', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', - 'private_key_id', + 'http_basic_auth_password', 'value', 'real_value', - 'http_basic_auth_password', + ]); + $this->exposeNestedServerSecrets($application); + } else { + $application->makeHidden([ + 'private_key_id', ]); } @@ -65,6 +90,34 @@ class ApplicationsController extends Controller return serializeApiResponse($application); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Models hide these by default via $hidden; this re-exposes them per-request. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all applications.', @@ -117,8 +170,12 @@ class ApplicationsController extends Controller } $tagName = $request->query('tag'); + $applicationRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; $applications = Application::ownedByCurrentTeamAPI($teamId) + ->with($applicationRelations) ->when($tagName, function ($query, $tagName) { $query->whereHas('tags', function ($query) use ($tagName) { $query->where('name', $tagName); @@ -170,6 +227,7 @@ class ApplicationsController extends Controller 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], '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.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -234,6 +292,7 @@ class ApplicationsController extends Controller 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -337,6 +396,7 @@ class ApplicationsController extends Controller 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], '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.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -400,6 +460,7 @@ class ApplicationsController extends Controller 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -503,6 +564,7 @@ class ApplicationsController extends Controller 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], '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.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], @@ -566,6 +628,7 @@ class ApplicationsController extends Controller 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -696,6 +759,7 @@ class ApplicationsController extends Controller 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], '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.'], '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'], @@ -704,6 +768,7 @@ class ApplicationsController extends Controller 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -830,6 +895,7 @@ class ApplicationsController extends Controller 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], '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.'], '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'], @@ -838,6 +904,7 @@ class ApplicationsController extends Controller 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], '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.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -914,7 +981,7 @@ class ApplicationsController extends Controller 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', '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', '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', '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']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -928,6 +995,8 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', 'autogenerate_domain' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -945,6 +1014,13 @@ class ApplicationsController extends Controller ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + $environmentUuid = $request->environment_uuid; $environmentName = $request->environment_name; if (blank($environmentUuid) && blank($environmentName)) { @@ -964,6 +1040,7 @@ class ApplicationsController extends Controller $isSpa = $request->is_spa; $isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled; + $isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); @@ -1091,7 +1168,7 @@ class ApplicationsController extends Controller $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1141,15 +1218,15 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); if ($git_host === 'github.com') { $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; + $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); } - $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); $application->fqdn = $fqdn; $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); @@ -1171,6 +1248,10 @@ class ApplicationsController extends Controller $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1197,6 +1278,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1332,7 +1416,7 @@ class ApplicationsController extends Controller $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1382,7 +1466,7 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1416,6 +1500,10 @@ class ApplicationsController extends Controller $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1436,6 +1524,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1545,7 +1636,7 @@ class ApplicationsController extends Controller $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1595,7 +1686,7 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -1625,6 +1716,10 @@ class ApplicationsController extends Controller $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1645,6 +1740,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1749,6 +1847,10 @@ class ApplicationsController extends Controller $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1765,6 +1867,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1868,6 +1973,10 @@ class ApplicationsController extends Controller $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1884,6 +1993,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1915,6 +2027,7 @@ class ApplicationsController extends Controller 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); + } return response()->json(['message' => 'Invalid type.'], 400); @@ -1977,7 +2090,7 @@ class ApplicationsController extends Controller if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2017,6 +2130,13 @@ class ApplicationsController extends Controller default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -2058,7 +2178,7 @@ class ApplicationsController extends Controller if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2080,8 +2200,9 @@ class ApplicationsController extends Controller ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines); + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, @@ -2151,7 +2272,7 @@ class ApplicationsController extends Controller if (! $request->uuid) { return response()->json(['message' => 'UUID is required.'], 404); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -2228,6 +2349,7 @@ class ApplicationsController extends Controller 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], '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.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -2287,6 +2409,7 @@ class ApplicationsController extends Controller '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.'], ], ) ), @@ -2362,7 +2485,7 @@ class ApplicationsController extends Controller return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ 'message' => 'Application not found', @@ -2372,7 +2495,7 @@ class ApplicationsController extends Controller $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', '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']; + $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']; $validationRules = [ 'name' => 'string|max:255', @@ -2385,8 +2508,10 @@ class ApplicationsController extends Controller 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', + 'is_preview_deployments_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', + 'include_source_commit_in_build' => 'boolean', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2541,7 +2666,7 @@ class ApplicationsController extends Controller $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -2600,10 +2725,12 @@ class ApplicationsController extends Controller $isSpa = $request->is_spa; $isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled; + $isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); + $includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build'); if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -2629,6 +2756,11 @@ class ApplicationsController extends Controller $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } + if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -2642,6 +2774,10 @@ class ApplicationsController extends Controller $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; $application->settings->save(); } + if ($request->has('include_source_commit_in_build')) { + $application->settings->include_source_commit_in_build = $includeSourceCommitInBuild; + $application->settings->save(); + } removeUnnecessaryFieldsFromRequest($request); $data = $request->only($allowedFields); @@ -3814,6 +3950,99 @@ class ApplicationsController extends Controller ); } + #[OA\Post( + summary: 'Move', + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.', + path: '/applications/{uuid}/move', + operationId: 'move-application-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the application to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Application moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Application moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + return moveResourceToEnvironment($request, $application, 'Application', $teamId); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); @@ -3949,6 +4178,7 @@ class ApplicationsController extends Controller $persistentStorages = $application->persistentStorages->sortBy('id')->values(); $fileStorages = $application->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -4163,7 +4393,7 @@ class ApplicationsController extends Controller 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Post( @@ -4397,7 +4627,7 @@ class ApplicationsController extends Controller 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Delete( @@ -4559,4 +4789,148 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Preview deletion request queued.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'list-tags-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'create-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from an application by UUID.', + path: '/applications/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index ad6eeb982..ad699e8f1 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -16,9 +16,14 @@ class CloudProviderTokensController extends Controller { $token->makeHidden([ 'id', - 'token', ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $token->makeVisible([ + 'token', + ]); + } + return serializeApiResponse($token); } @@ -37,6 +42,9 @@ class CloudProviderTokensController extends Controller 'digitalocean' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.digitalocean.com/v2/account'), + 'vultr' => Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'), default => null, }; @@ -82,7 +90,7 @@ class CloudProviderTokensController extends Controller properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -200,7 +208,7 @@ class CloudProviderTokensController extends Controller type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], ], @@ -255,7 +263,7 @@ class CloudProviderTokensController extends Controller $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php new file mode 100644 index 000000000..4c15d0726 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -0,0 +1,174 @@ +findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('view', $resource); + + return response()->json($resource->tags->map(TagsController::serializeTag(...))); + } + + public function createTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + if ($request->has('tag_name') && $request->has('tag_names')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']], + ], 422); + } + + $validator = Validator::make($request->all(), [ + 'tag_name' => 'required_without:tag_names|string', + 'tag_names' => 'required_without:tag_name|array|min:1', + 'tag_names.*' => 'string', + ]); + + $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $this->attachTagsToResource($resource, $tagNames, $teamId); + + return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201); + } + + public function deleteTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + $tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + if (! $resource->tags()->whereKey($tag->id)->exists()) { + return response()->json(['message' => 'Tag not found on resource.'], 404); + } + + $resource->tags()->detach($tag->id); + $tag->deleteIfOrphaned(); + + return response()->json(['message' => 'Tag removed.']); + } + + protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void + { + foreach ($this->normalizeTagNames($tagNames) as $tagName) { + if (mb_strlen($tagName) < 2) { + continue; + } + + $tag = Tag::query()->createOrFirst([ + 'team_id' => $teamId, + 'name' => $tagName, + ]); + + $resource->tags()->syncWithoutDetaching([$tag->id]); + } + } + + protected function validateTagsParameter(Request $request): ?JsonResponse + { + if (! $request->has('tags')) { + return null; + } + + $tagNames = $this->normalizeTagNames($request->input('tags', [])); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $request->merge(['tags' => $tagNames]); + + return null; + } + + protected function normalizeTagNames(array $tagNames): array + { + return collect($tagNames) + ->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName)))) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 912f81728..3761effd7 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -20,6 +20,7 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -27,28 +28,123 @@ use OpenApi\Attributes as OA; class DatabasesController extends Controller { - private function removeSensitiveData($database) + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return queryDatabaseByUuidWithinTeam($uuid, $teamId); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Database not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + + private function removeSensitiveData($database, bool $loadNestedServerSecrets = false) { $database->makeHidden([ 'id', 'laravel_through_key', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $database->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $database->makeVisible([ 'internal_db_url', 'external_db_url', + 'init_scripts', 'postgres_password', 'dragonfly_password', 'redis_password', 'mongo_initdb_root_password', 'keydb_password', 'clickhouse_admin_password', + 'mysql_password', + 'mysql_root_password', + 'mariadb_password', + 'mariadb_root_password', ]); + $this->exposeNestedServerSecrets($database); + } else { + $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); } return serializeApiResponse($database); } + private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void + { + if ($loadRelations) { + $server = data_get($model, 'destination.server'); + } else { + if (! $model->relationLoaded('destination')) { + return; + } + + $destination = $model->getRelation('destination'); + if (! $destination || ! $destination->relationLoaded('server')) { + return; + } + + $server = $destination->getRelation('server'); + } + + if (! $server) { + return; + } + + $server->makeHidden([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($loadRelations || $server->relationLoaded('settings')) { + $server->settings->makeHidden([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + */ + private function exposeNestedServerSecrets(Model $model): void + { + $server = $model->destination?->server; + if ($server === null) { + return; + } + + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($server->settings !== null) { + $server->settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all databases.', @@ -85,8 +181,12 @@ class DatabasesController extends Controller } $projects = Project::where('team_id', $teamId)->get(); $databases = collect(); + $databaseRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $databases = $databases->merge($project->databases()); + $databases = $databases->merge($project->databases($databaseRelations)); } $databaseIds = $databases->pluck('id')->toArray(); @@ -228,7 +328,7 @@ class DatabasesController extends Controller $this->authorize('view', $database); - return response()->json($this->removeSensitiveData($database)); + return response()->json($this->removeSensitiveData($database, loadNestedServerSecrets: true)); } #[OA\Patch( @@ -1132,6 +1232,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1200,6 +1301,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1267,6 +1369,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1335,6 +1438,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1403,6 +1507,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1474,6 +1579,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1545,6 +1651,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1613,6 +1720,7 @@ class DatabasesController extends Controller 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1643,7 +1751,7 @@ class DatabasesController extends Controller public function create_database(Request $request, NewDatabaseTypes $type) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1742,6 +1850,8 @@ class DatabasesController extends Controller 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'instant_deploy' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); if ($validator->failed()) { return response()->json([ @@ -1749,6 +1859,13 @@ class DatabasesController extends Controller 'errors' => $validator->errors(), ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + if ($request->public_port) { if ($request->public_port < 1024 || $request->public_port > 65535) { return response()->json([ @@ -1760,7 +1877,7 @@ class DatabasesController extends Controller } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1808,6 +1925,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ 'uuid' => $database->uuid, @@ -1829,7 +1949,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1876,6 +1996,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -1898,7 +2021,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1945,6 +2068,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -1967,7 +2093,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', @@ -2011,6 +2137,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2033,7 +2162,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); @@ -2058,12 +2187,15 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } return response()->json(serializeApiResponse([ 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', @@ -2107,6 +2239,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2129,7 +2264,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -2153,6 +2288,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2175,7 +2313,7 @@ class DatabasesController extends Controller return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), @@ -2221,6 +2359,9 @@ class DatabasesController extends Controller if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2247,6 +2388,116 @@ class DatabasesController extends Controller return response()->json(['message' => 'Invalid database type requested.'], 400); } + #[OA\Get( + summary: 'Get database logs.', + description: 'Get database logs by UUID.', + path: '/databases/{uuid}/logs', + operationId: 'get-database-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get database logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $container = $containers->first(); + + $status = getContainerStatus($database->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete database by UUID.', @@ -2692,6 +2943,99 @@ class DatabasesController extends Controller ]); } + #[OA\Post( + summary: 'Move', + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.', + path: '/databases/{uuid}/move', + operationId: 'move-database-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the database to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + return moveResourceToEnvironment($request, $database, 'Database', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', @@ -2970,8 +3314,8 @@ class DatabasesController extends Controller 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $env->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $env->makeVisible([ 'value', 'real_value', ]); @@ -3611,6 +3955,7 @@ class DatabasesController extends Controller $persistentStorages = $database->persistentStorages->sortBy('id')->values(); $fileStorages = $database->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -3849,7 +4194,7 @@ class DatabasesController extends Controller 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -4056,7 +4401,7 @@ class DatabasesController extends Controller 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( @@ -4143,4 +4488,148 @@ class DatabasesController extends Controller return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'list-tags-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'create-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a database by UUID.', + path: '/databases/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 182080044..34f47d289 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -24,6 +24,10 @@ class DeployController extends Controller $deployment->makeHidden([ 'logs', ]); + } else { + $deployment->makeVisible([ + 'logs', + ]); } return serializeApiResponse($deployment); @@ -365,7 +369,7 @@ class DeployController extends Controller $uuids = $request->input('uuid'); $tags = $request->input('tag'); - $force = $request->input('force') ?? false; + $force = $request->boolean('force'); $pullRequestId = $request->input('pull_request_id', $request->input('pr')); $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; @@ -698,6 +702,9 @@ class DeployController extends Controller $this->authorize('view', $application); $deployments = $application->deployments($skip, $take); + if ($request->attributes->get('can_read_sensitive', false) === true) { + $deployments['deployments']->each->makeVisible(['logs']); + } return response()->json($deployments); } diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php new file mode 100644 index 000000000..fc51e7c01 --- /dev/null +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -0,0 +1,382 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function digitalOceanToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'digitalocean') + ->first(); + + if (! $token) { + return response()->json(['message' => 'DigitalOcean cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } + + #[OA\Get( + path: '/digitalocean/regions', + operationId: 'get-digitalocean-regions', + summary: 'Get DigitalOcean regions', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function regions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean regions.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/sizes', + operationId: 'get-digitalocean-sizes', + summary: 'Get DigitalOcean sizes', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean sizes.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sizes(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSizes()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean sizes.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/images', + operationId: 'get-digitalocean-images', + summary: 'Get DigitalOcean images', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean images.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function images(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getImages()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean images.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/ssh-keys', + operationId: 'get-digitalocean-ssh-keys', + summary: 'Get DigitalOcean SSH keys', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean SSH keys.'], 500); + } + } + + #[OA\Post( + path: '/servers/digitalocean', + operationId: 'create-digitalocean-server', + summary: 'Create a server on DigitalOcean', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + responses: [ + new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'size', + 'image', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'monitoring', + 'digitalocean_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [Server::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'size' => 'required|string', + 'image' => 'required', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'monitoring' => 'nullable|boolean', + 'digitalocean_ssh_key_ids' => 'nullable|array', + 'digitalocean_ssh_key_ids.*' => 'integer', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $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); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + $request->offsetSet('name', $request->name ?: generate_random_name()); + $request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true)); + $request->offsetSet('monitoring', $request->boolean('monitoring', true)); + $request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []); + $request->offsetSet('instant_validate', $request->boolean('instant_validate', false)); + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $digitalOceanService = new DigitalOceanService($token->token); + $sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey); + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $request->digitalocean_ssh_key_ids + ))); + + $normalizedServerName = strtolower(trim($request->name)); + $params = [ + 'name' => $normalizedServerName, + 'region' => $request->region, + 'size' => $request->size, + 'image' => $request->image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $request->enable_ipv6, + 'monitoring' => $request->monitoring, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $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 = 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); + } + + auditLog('api.digitalocean_droplet.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable $e) { + logger()->error('Failed to create DigitalOcean server', [ + 'error' => $e->getMessage(), + ]); + + return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500); + } + } + + private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int + { + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + return (int) $key['id']; + } + } + + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + + return (int) $uploadedKey['id']; + } +} diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 150743f99..5c073e9c0 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -17,10 +17,17 @@ class GithubController extends Controller { private function removeSensitiveData($githubApp) { - $githubApp->makeHidden([ - 'client_secret', - 'webhook_secret', - ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $githubApp->makeVisible([ + 'client_secret', + 'webhook_secret', + ]); + } else { + $githubApp->makeHidden([ + 'client_secret', + 'webhook_secret', + ]); + } return serializeApiResponse($githubApp); } @@ -129,7 +136,7 @@ class GithubController extends Controller 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], ], - required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], + required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], ), ), ], @@ -205,10 +212,14 @@ class GithubController extends Controller 'is_system_wide', ]; + $request->merge([ + 'organization' => normalizeGithubOrganization($request->input('organization')), + ]); + $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', - 'organization' => 'nullable|string|max:255', - 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'], + 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'nullable|string|max:255', 'custom_port' => 'nullable|integer|min:1|max:65535', @@ -252,7 +263,9 @@ class GithubController extends Controller 'uuid' => Str::uuid(), 'name' => $request->input('name'), 'organization' => $request->input('organization'), - 'api_url' => $request->input('api_url'), + 'api_url' => filled($request->input('api_url')) + ? $request->input('api_url') + : githubApiUrlFromHtmlUrl($request->input('html_url')), 'html_url' => $request->input('html_url'), 'custom_user' => $request->input('custom_user', 'git'), 'custom_port' => $request->input('custom_port', 22), @@ -589,13 +602,17 @@ class GithubController extends Controller $payload = $request->only($allowedFields); + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + // Validate the request $rules = []; if (isset($payload['name'])) { $rules['name'] = 'string'; } if (isset($payload['organization'])) { - $rules['organization'] = 'nullable|string'; + $rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/']; } if (isset($payload['api_url'])) { $rules['api_url'] = ['url', new SafeExternalUrl]; @@ -639,6 +656,13 @@ class GithubController extends Controller ], 422); } + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) { + $payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']); + } + // Handle private_key_uuid -> private_key_id conversion if (isset($payload['private_key_uuid'])) { $privateKey = PrivateKey::where('team_id', $teamId) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 1c9d6f9ef..4cadc0eb6 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -460,6 +460,195 @@ class HetznerController extends Controller } } + #[OA\Get( + summary: 'Get Hetzner Firewalls', + description: 'Get all existing Hetzner firewalls for the current project.', + path: '/hetzner/firewalls', + operationId: 'get-hetzner-firewalls', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner firewalls.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function firewalls(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + $this->authorize('view', $token); + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getFirewalls()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner Networks', + description: 'Get all existing Hetzner private networks for the current project.', + path: '/hetzner/networks', + operationId: 'get-hetzner-networks', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner networks.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'ip_range' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function networks(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + $this->authorize('view', $token); + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getNetworks()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); + } + } + #[OA\Post( summary: 'Create Hetzner Server', description: 'Create a new server on Hetzner and register it in Coolify.', @@ -487,7 +676,10 @@ class HetznerController extends Controller 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], + 'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], + 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], + 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], ], @@ -545,7 +737,10 @@ class HetznerController extends Controller 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', + 'enable_backups', 'hetzner_ssh_key_ids', + 'hetzner_firewall_ids', + 'hetzner_network_ids', 'cloud_init_script', 'instant_validate', ]; @@ -571,8 +766,13 @@ class HetznerController extends Controller 'private_key_uuid' => 'required|string', 'enable_ipv4' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean', + 'enable_backups' => 'nullable|boolean', 'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids.*' => 'integer', + 'hetzner_firewall_ids' => 'nullable|array', + 'hetzner_firewall_ids.*' => 'integer', + 'hetzner_network_ids' => 'nullable|array', + 'hetzner_network_ids.*' => 'integer', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'instant_validate' => 'nullable|boolean', ]); @@ -608,13 +808,32 @@ class HetznerController extends Controller if (is_null($request->enable_ipv6)) { $request->offsetSet('enable_ipv6', true); } + if (is_null($request->enable_backups)) { + $request->offsetSet('enable_backups', false); + } if (is_null($request->hetzner_ssh_key_ids)) { $request->offsetSet('hetzner_ssh_key_ids', []); } + if (is_null($request->hetzner_firewall_ids)) { + $request->offsetSet('hetzner_firewall_ids', []); + } + if (is_null($request->hetzner_network_ids)) { + $request->offsetSet('hetzner_network_ids', []); + } if (is_null($request->instant_validate)) { $request->offsetSet('instant_validate', false); } + if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv4' => ['Enable at least one public IP protocol.'], + 'enable_ipv6' => ['Enable at least one public IP protocol.'], + ], + ], 422); + } + // Validate cloud provider token $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) @@ -687,6 +906,18 @@ class HetznerController extends Controller ], ]; + $firewallIds = array_values(array_unique($request->hetzner_firewall_ids)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($request->hetzner_network_ids)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($request->cloud_init_script)) { $params['user_data'] = $request->cloud_init_script; @@ -723,6 +954,14 @@ class HetznerController extends Controller $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($request->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + // Validate server if requested if ($request->instant_validate) { ValidateServer::dispatch($server); diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 92f19c7ae..ea3b54e80 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -166,6 +166,9 @@ class ProjectController extends Controller return response()->json(['message' => 'Environment not found.'], 404); } $environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']); + collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']) + ->flatMap(fn (string $relation) => $environment->{$relation}) + ->each(fn ($resource) => exposeSensitiveFields($resource)); return response()->json(serializeApiResponse($environment)); } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index d5dc4a046..53d542d44 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -56,6 +56,7 @@ class ResourcesController extends Controller } $resources = $resources->flatten(); $resources = $resources->map(function ($resource) { + exposeSensitiveFields($resource); $payload = $resource->toArray(); $payload['status'] = $resource->status; $payload['type'] = $resource->type(); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 0559bcd99..25430631b 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -16,6 +16,10 @@ class SecurityController extends Controller $team->makeHidden([ 'private_key', ]); + } else { + $team->makeVisible([ + 'private_key', + ]); } return serializeApiResponse($team); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 3af05f4fa..8c82fa2ad 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -97,11 +97,6 @@ class SentinelController extends Controller if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); - - auditLog('sentinel.metrics_pushed', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); } return response()->json(['message' => 'ok'], 200); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index f4efc8577..fa0017f87 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -23,9 +23,14 @@ class ServersController extends Controller { private function removeSensitiveDataFromSettings($settings) { - if (request()->attributes->get('can_read_sensitive', false) === false) { - $settings = $settings->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $settings = $settings->makeVisible([ 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', ]); } @@ -37,8 +42,11 @@ class ServersController extends Controller $server->makeHidden([ 'id', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - // Do nothing + if (request()->attributes->get('can_read_sensitive', false) === true) { + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); } return serializeApiResponse($server); @@ -854,7 +862,11 @@ class ServersController extends Controller false, // Don't delete from Hetzner via API $server->hetzner_server_id, $server->cloud_provider_token_id, - $server->team_id + $server->team_id, + false, // Don't delete from Vultr via API + $server->vultr_instance_id, + false, // Don't delete from DigitalOcean via API + $server->digitalocean_droplet_id ); auditLog('api.server.deleted', [ diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 000000000..a144b3ea6 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,766 @@ +makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + $serialized = serializeApiResponse($serviceApplication); + + if ($serialized instanceof Collection) { + return $serialized->all(); + } + + return (array) $serialized; + } + + private function resolveService(Request $request, int $teamId): ?Service + { + $uuid = $request->route('uuid'); + if (! $uuid) { + return null; + } + + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($uuid) + ->first(); + } + + private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication + { + $appUuid = $request->route('app_uuid'); + if (! $appUuid) { + return null; + } + + return $service->applications() + ->where('uuid', $appUuid) + ->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 applications', + description: 'List compose service applications (containers) for a single service.', + path: '/services/{uuid}/applications', + operationId: 'list-service-applications-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + 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 applications for this service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + 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); + + $items = $service->applications() + ->get() + ->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa)); + + return response()->json($items); + } + + #[OA\Get( + summary: 'Get service application', + description: 'Get a single compose service application by service UUID and application UUID.', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'get-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(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); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Patch( + summary: 'Update service application', + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'patch-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force_domain_override', + in: 'query', + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + requestBody: new OA\RequestBody( + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'url' => new OA\Property( + property: 'url', + type: 'string', + nullable: true, + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + ), + 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), + 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), + 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), + 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), + 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), + 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(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: 'Domain conflicts (unless force_domain_override).', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('update', $serviceApplication); + + $payload = $request->json()->all(); + if (empty($payload)) { + $payload = $request->request->all(); + } + + $allowedFields = [ + 'url', + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + ]; + + $validationRules = [ + 'url' => 'nullable|string', + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_gzip_enabled' => 'sometimes|boolean', + 'is_stripprefix_enabled' => 'sometimes|boolean', + ]; + + $validator = Validator::make($payload, $validationRules); + + $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); + } + + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload); + if ($response instanceof JsonResponse) { + return $response; + } + + $serviceApplication->refresh(); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Get( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'get-service-application-logs-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Service application container is not running.', + ], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + $logs = getContainerLogs($server, $containerName, $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[OA\Get( + 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: 'start-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force', + in: 'query', + description: 'When true, passes --build to docker compose up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'When true, pulls the image for this compose service before up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $pullLatest = $request->boolean('latest', false); + $forceRebuild = $request->boolean('force', false); + + DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild); + + return response()->json([ + 'message' => 'Service application deploy request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Restart service application container', + description: 'Restarts a single compose service container (docker restart).', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'restart-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + RestartServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application restart request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Stop service application container', + description: 'Stops a single compose service container (docker stop).', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'stop-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + 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(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + StopServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application stop request queued.', + ], 200); + } +} diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 97fd41c5c..aa9afe0ad 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -14,29 +14,57 @@ use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; class ServicesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Service not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($service) { + if ($service instanceof Collection) { + return $service->map(fn (Service $item) => $this->removeSensitiveData($item)); + } + $service->makeHidden([ 'id', 'resourceable', 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $service->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $service->makeVisible([ 'docker_compose_raw', 'docker_compose', 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($service); } if ($service->is_shown_once ?? false) { @@ -46,6 +74,42 @@ class ServicesController extends Controller return serializeApiResponse($service); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Handles both single models and Eloquent Collections (the listing endpoint + * passes a Collection of Services per project to removeSensitiveData()). + */ + private function exposeNestedServerSecrets(Model|Collection $model): void + { + if ($model instanceof Collection) { + foreach ($model as $item) { + $this->exposeNestedServerSecrets($item); + } + + return; + } + $server = $model->destination?->server ?? $model->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; @@ -170,8 +234,12 @@ class ServicesController extends Controller } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); + $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $services->push($project->services()->get()); + $services->push($project->services()->with($serviceRelations)->get()); } foreach ($services as $service) { $service = $this->removeSensitiveData($service); @@ -220,6 +288,7 @@ class ServicesController extends Controller ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'], ], ), ), @@ -286,7 +355,7 @@ class ServicesController extends Controller )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -316,6 +385,8 @@ class ServicesController extends Controller 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -337,6 +408,11 @@ class ServicesController extends Controller ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + if (filled($request->type) && filled($request->docker_compose_raw)) { return response()->json([ 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', @@ -475,6 +551,10 @@ class ServicesController extends Controller } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -495,7 +575,7 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $validationRules = [ 'project_uuid' => 'string|required', @@ -514,6 +594,8 @@ class ServicesController extends Controller 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -647,6 +729,10 @@ class ServicesController extends Controller } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -726,11 +812,135 @@ class ServicesController extends Controller $this->authorize('view', $service); - $service = $service->load(['applications', 'databases']); + $serviceRelations = ['applications', 'databases']; + if ($request->attributes->get('can_read_sensitive', false) === true) { + $serviceRelations[] = 'destination.server.settings'; + } + + $service = $service->load($serviceRelations); return response()->json($this->removeSensitiveData($service)); } + #[OA\Get( + summary: 'Get service logs.', + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.', + path: '/services/{uuid}/logs', + operationId: 'get-service-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'sub_service_name', + in: 'query', + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.', + required: true, + schema: new OA\Schema(type: 'string', example: 'appwrite-console'), + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get service logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $subServiceName = $request->query->get('sub_service_name'); + if (! $subServiceName) { + return response()->json(['message' => 'Sub service name is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $name = "{$subServiceName}-{$service->uuid}"; + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); + $container = $containers->first(); + + if (! $container) { + return response()->json(['message' => 'Container not found.'], 404); + } + + $status = getContainerStatus($service->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Container is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete service by UUID.', @@ -1659,6 +1869,99 @@ class ServicesController extends Controller return response()->json(['message' => 'Environment variable deleted.']); } + #[OA\Post( + summary: 'Move', + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.', + path: '/services/{uuid}/move', + operationId: 'move-service-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the service to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Service moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Service moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + return moveResourceToEnvironment($request, $service, 'Service', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start service. `Post` request is also accepted.', @@ -2018,6 +2321,8 @@ class ServicesController extends Controller ); } + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); + return response()->json([ 'persistent_storages' => $persistentStorages->sortBy('id')->values(), 'file_storages' => $fileStorages->sortBy('id')->values(), @@ -2265,7 +2570,7 @@ class ServicesController extends Controller 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -2502,7 +2807,7 @@ class ServicesController extends Controller 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( @@ -2616,4 +2921,148 @@ class ServicesController extends Controller return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'list-tags-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'create-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a service by UUID.', + path: '/services/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php new file mode 100644 index 000000000..173a8ab7b --- /dev/null +++ b/app/Http/Controllers/Api/TagsController.php @@ -0,0 +1,61 @@ + $tag->uuid, + 'name' => $tag->name, + 'created_at' => $tag->created_at, + 'updated_at' => $tag->updated_at, + ]; + } + + #[OA\Get( + summary: 'List', + description: 'List all tags for the current team.', + path: '/tags', + operationId: 'list-tags', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + responses: [ + new OA\Response( + response: 200, + description: 'All tags for the current team.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function tags(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tags = Tag::where('team_id', $teamId)->orderBy('name')->get(); + + return response()->json($tags->map(self::serializeTag(...))); + } +} diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php new file mode 100644 index 000000000..7aaba568e --- /dev/null +++ b/app/Http/Controllers/Api/VultrController.php @@ -0,0 +1,402 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function getVultrToken(Request $request): CloudProviderToken|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + return $token; + } + + #[OA\Get( + summary: 'Get Vultr Regions', + description: 'Get all available Vultr regions.', + path: '/vultr/regions', + operationId: 'get-vultr-regions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function regions(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr Plans', + description: 'Get all available Vultr plans.', + path: '/vultr/plans', + operationId: 'get-vultr-plans', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr plans.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function plans(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getPlans()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr Operating Systems', + description: 'Get all available Vultr operating systems.', + path: '/vultr/os', + operationId: 'get-vultr-operating-systems', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr operating systems.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function operatingSystems(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getOperatingSystems()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr SSH Keys', + description: 'Get all Vultr SSH keys available to the selected token.', + path: '/vultr/ssh-keys', + operationId: 'get-vultr-ssh-keys', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500); + } + } + + #[OA\Post( + summary: 'Create Vultr Server', + description: 'Create a Vultr instance and link it as a Coolify server.', + path: '/servers/vultr', + operationId: 'create-vultr-server', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 201, description: 'Vultr server created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'plan', + 'os_id', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'disable_public_ipv4', + 'vultr_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'plan' => 'required|string', + 'os_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'disable_public_ipv4' => 'nullable|boolean', + 'vultr_ssh_key_ids' => 'nullable|array', + 'vultr_ssh_key_ids.*' => 'string', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $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); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + if (! $request->name) { + $request->offsetSet('name', generate_random_name()); + } + if (is_null($request->enable_ipv6)) { + $request->offsetSet('enable_ipv6', true); + } + if (is_null($request->disable_public_ipv4)) { + $request->offsetSet('disable_public_ipv4', false); + } + if (is_null($request->vultr_ssh_key_ids)) { + $request->offsetSet('vultr_ssh_key_ids', []); + } + if (is_null($request->instant_validate)) { + $request->offsetSet('instant_validate', false); + } + + if ($request->disable_public_ipv4 && ! $request->enable_ipv6) { + return $this->networkConfigurationErrorResponse(); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $vultrService = new VultrService($token->token); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $normalizedServerName = strtolower(trim($request->name)); + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids))); + + $params = [ + 'region' => $request->region, + 'plan' => $request->plan, + 'os_id' => $request->os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $request->enable_ipv6, + 'disable_public_ipv4' => $request->disable_public_ipv4, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $vultrInstance = $vultrService->createInstance($params); + $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0'; + + $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->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($request->instant_validate) { + ValidateServer::dispatch($server); + } + + auditLog('api.vultr_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable) { + return response()->json(['message' => 'Failed to create Vultr server.'], 500); + } + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } + + private function networkConfigurationErrorResponse(): JsonResponse + { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'], + ], + ], 422); + } +} diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 8d7c51d11..05d1b3e6e 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -11,8 +11,9 @@ class ApiSensitiveData { $token = $request->user()->currentAccessToken(); $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); - $teamId = (int) data_get($token, 'team_id'); - $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; + $teamId = data_get($token, 'team_id'); + // team_id 0 is the instance-admin team, so a falsy check must not exclude it + $isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false; // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 61fc3d4ee..5186a58bb 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -96,7 +96,16 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue return; } - if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { + $repository_parts = explode('/', $this->fullName); + $owner = $repository_parts[0] ?? ''; + $repo = $repository_parts[1] ?? ''; + $headCommitMessage = null; + + if ($this->action === 'opened' || $this->action === 'synchronize' || $this->action === 'reopened') { + $headCommitMessage = getGithubCommitMessage($githubApp, $owner, $repo, $this->commitSha); + } + + if (self::shouldSkipDeployAny([$this->pullRequestTitle, $headCommitMessage])) { return; } @@ -120,9 +129,6 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue // Get changed files for watch path filtering $changed_files = collect(); - $repository_parts = explode('/', $this->fullName); - $owner = $repository_parts[0] ?? ''; - $repo = $repository_parts[1] ?? ''; if ($this->action === 'synchronize' && $this->beforeSha && $this->afterSha) { // For synchronize events, get files changed between before and after commits diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 83f474ccc..60adf8c18 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -67,6 +67,14 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue $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(); @@ -185,6 +193,41 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue } + 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/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 4d9b1af35..bf64ee8e9 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1134,6 +1134,12 @@ class GlobalSearch extends Component public function navigateToResource($type) { + if ($type === 'server') { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, 'server.create'); + } + // Find the item by type - check regular items first, then services $item = collect($this->creatableItems)->firstWhere('type', $type); diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 37b8b277a..61d04e20b 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -91,9 +91,7 @@ class Tags extends Component $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); - if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { - $found_more_tags->delete(); - } + $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); } catch (\Exception $e) { diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php new file mode 100644 index 000000000..6e2a3937d --- /dev/null +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -0,0 +1,97 @@ + 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function mount(string $cloud_init_script_uuid): void + { + try { + $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() + ->whereUuid($cloud_init_script_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudInitScript); + + $this->name = $this->cloudInitScript->name; + $this->script = $this->cloudInitScript->script; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud-init script.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudInitScript); + $this->validate(); + + $this->cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $this->cloudInitScript->id, + 'cloud_init_script_name' => $this->cloudInitScript->name, + ]); + + $this->dispatch('success', 'Cloud-init script updated successfully.'); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudInitScript); + + $scriptId = $this->cloudInitScript->id; + $scriptName = $this->cloudInitScript->name; + + $this->cloudInitScript->delete(); + + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + + return redirectRoute($this, 'security.cloud-init-scripts'); + } + + public function render() + { + return view('livewire.security.cloud-init-script.show'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 57b7324d3..e66a749cf 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -27,6 +27,13 @@ class CloudInitScripts extends Component public function loadScripts() { + CloudInitScript::ownedByCurrentTeam() + ->whereNull('uuid') + ->get() + ->each(function (CloudInitScript $script): void { + $script->forceFill(['uuid' => new_public_id()])->save(); + }); + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php new file mode 100644 index 000000000..aa9270be0 --- /dev/null +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -0,0 +1,177 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Token name is required.', + ]; + } + + public function mount(string $cloud_token_uuid): void + { + try { + $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() + ->whereUuid($cloud_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudProviderToken); + + $this->name = $this->cloudProviderToken->name; + $this->description = $this->cloudProviderToken->description; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud token.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudProviderToken); + $this->validate(); + + $description = trim($this->description ?? ''); + + $this->cloudProviderToken->update([ + 'name' => $this->name, + 'description' => $description === '' ? null : $description, + ]); + + auditLog('ui.cloud_token.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->dispatch('success', 'Cloud provider token updated.'); + } + + public function validateToken(): void + { + $this->authorize('view', $this->cloudProviderToken); + + $isValid = match ($this->cloudProviderToken->provider) { + 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), + 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), + 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + default => false, + }; + + $providerName = $this->providerName(); + + $this->dispatch( + $isValid ? 'success' : 'error', + $isValid + ? "{$providerName} token is valid." + : "{$providerName} token validation failed. Please check the token." + ); + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + 'valid' => $isValid, + ]); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudProviderToken); + + if ($this->cloudProviderToken->hasServers()) { + $serverCount = $this->cloudProviderToken->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return null; + } + + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->cloudProviderToken->delete(); + + return redirectRoute($this, 'security.cloud-tokens'); + } + + public function providerName(): string + { + return match ($this->cloudProviderToken->provider) { + 'digitalocean' => 'DigitalOcean', + 'vultr' => 'Vultr', + default => 'Hetzner', + }; + } + + private function validateHetznerToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.hetzner.cloud/v1/servers?per_page=1') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateDigitalOceanToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateVultrToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 6d0efa15f..c2466c622 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -2,6 +2,9 @@ namespace App\Livewire\Security; +use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; +use App\Livewire\Server\New\ByDigitalOcean; +use App\Livewire\Server\New\ByHetzner; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -19,6 +22,8 @@ class CloudProviderTokenForm extends Component public string $name = ''; + public ?string $description = null; + public function mount() { try { @@ -31,9 +36,10 @@ class CloudProviderTokenForm extends Component protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', ]; } @@ -58,8 +64,22 @@ class CloudProviderTokenForm extends Component return $response->successful(); } - // Add other providers here in the future - // if ($provider === 'digitalocean') { ... } + if ($provider === 'digitalocean') { + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account'); + + return $response->successful(); + } + + if ($provider === 'vultr') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } return false; } catch (\Throwable $e) { @@ -77,11 +97,14 @@ class CloudProviderTokenForm extends Component return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); } + $description = trim($this->description ?? ''); + $savedToken = CloudProviderToken::create([ 'team_id' => currentTeam()->id, 'provider' => $this->provider, 'token' => $this->token, 'name' => $this->name, + 'description' => $description === '' ? null : $description, ]); auditLog('ui.cloud_token.created', [ @@ -91,10 +114,24 @@ class CloudProviderTokenForm extends Component 'provider' => $savedToken->provider, ]); - $this->reset(['token', 'name']); + $this->reset(['token', 'name', 'description']); // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); + $this->dispatch('tokenAdded', tokenId: $savedToken->id)->to(CloudProviderTokens::class); + + if ($savedToken->provider === 'digitalocean') { + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + } + + if ($savedToken->provider === 'hetzner') { + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ByHetzner::class); + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } $this->dispatch('success', 'Cloud provider token added successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index dabb199ed..e94aa087b 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -55,6 +55,13 @@ class CloudProviderTokens extends Component } else { $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.'); } + } elseif ($token->provider === 'vultr') { + $isValid = $this->validateVultrToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Vultr token is valid.'); + } else { + $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -97,6 +104,19 @@ class CloudProviderTokens extends Component } } + private function validateVultrToken(string $token): bool + { + try { + $response = Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + public function deleteToken(int $tokenId) { try { diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 8b7ba73dd..8be1011d5 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -43,22 +43,6 @@ class Create extends Component ); } - public function generateNewRSAKey() - { - $this->generateNewKey('rsa'); - } - - public function generateNewEDKey() - { - $this->generateNewKey('ed25519'); - } - - private function generateNewKey($type) - { - $keyData = PrivateKey::generateNewKeyPair($type); - $this->setKeyData($keyData); - } - public function updated($property) { if ($property === 'value') { @@ -93,14 +77,6 @@ class Create extends Component } } - private function setKeyData(array $keyData) - { - $this->name = $keyData['name']; - $this->description = $keyData['description']; - $this->value = $keyData['private_key']; - $this->publicKey = $keyData['public_key']; - } - private function validatePrivateKey() { $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 0362b65fa..540ef5fa1 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,31 @@ class Index extends Component { use AuthorizesRequests; + public function generatePrivateKey(string $type) + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index fa7397d13..826289b88 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -22,8 +23,12 @@ class Show extends Component public bool $isGitRelated = false; + public bool $isInUse = false; + public $public_key = 'Loading...'; + public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.'; + protected function rules(): array { return [ @@ -74,16 +79,17 @@ class Show extends Component } } - public function mount() + public function mount(?string $private_key_uuid = null) { try { - $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail(); // Explicit authorization check - will throw 403 if not authorized $this->authorize('view', $this->private_key); $this->syncData(false); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + $this->isInUse = $this->private_key->isInUse(); + } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { abort(404); @@ -102,7 +108,15 @@ class Show extends Component { try { $this->authorize('delete', $this->private_key); - $this->private_key->safeDelete(); + + if ($this->private_key->isInUse()) { + $this->isInUse = true; + $this->dispatch('error', $this->deleteDisabledReason); + + return; + } + + $this->private_key->delete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); return redirectRoute($this, 'security.private-key.index'); diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index e3232d3f3..a42a68804 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -18,6 +18,10 @@ class Show extends Component public $parameters = []; + public string $provider = 'hetzner'; + + public string $providerName = 'Hetzner'; + public function mount(string $server_uuid) { try { @@ -31,14 +35,17 @@ class Show extends Component public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', ]; } public function loadTokens() { + $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; + $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() - ->where('provider', 'hetzner') + ->where('provider', $this->provider) ->get(); } @@ -78,7 +85,7 @@ class Show extends Component 'provider' => $ownedToken->provider, ]); - $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('success', "{$this->providerName} token updated successfully."); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { $this->server->refresh(); @@ -89,10 +96,13 @@ class Show extends Component private function validateTokenForServer(CloudProviderToken $token): array { try { - // First, validate the token itself + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if (! $response->successful()) { return [ @@ -101,7 +111,6 @@ class Show extends Component ]; } - // Check if this token can access the specific Hetzner server if ($this->server->hetzner_server_id) { $serverResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -115,6 +124,19 @@ class Show extends Component } } + if ($this->server->vultr_instance_id) { + $serverResponse = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this instance. It may belong to a different Vultr account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -129,19 +151,23 @@ class Show extends Component try { $token = $this->server->cloudProviderToken; if (! $token) { - $this->dispatch('error', 'No Hetzner token is associated with this server.'); + $this->dispatch('error', "No {$this->providerName} token is associated with this server."); return; } + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if ($response->successful()) { - $this->dispatch('success', 'Hetzner token is valid and working.'); + $this->dispatch('success', "{$this->providerName} token is valid and working."); } else { - $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + $this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions."); } auditLog('ui.server.cloud_token_validated', [ diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 5fd2ea4f7..7edfcaf72 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -11,12 +11,21 @@ class Create extends Component { public $private_keys = []; + public ?string $selectedType = null; + + public ?string $selectedTokenUuid = null; + public bool $limit_reached = false; public bool $has_hetzner_tokens = false; - public function mount() + public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + ? $selectedType + : null; + $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php new file mode 100644 index 000000000..158f28351 --- /dev/null +++ b/app/Livewire/Server/CreatePage.php @@ -0,0 +1,55 @@ +type = $type; + $this->token_uuid = $token_uuid; + $this->tokenProvider = match ($type) { + 'hetzner' => 'hetzner', + 'vultr' => 'vultr', + 'digital-ocean' => 'digitalocean', + default => null, + }; + $this->tokenProviderName = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + default => null, + }; + $this->hasProviderTokens = $this->tokenProvider + ? CloudProviderToken::ownedByCurrentTeam()->where('provider', $this->tokenProvider)->exists() + : false; + $this->title = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + 'manual' => 'Manual', + default => 'New Server', + }; + } + + public function render(): View + { + return view('livewire.server.create-page'); + } +} diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index d06543b39..f53339eed 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,10 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $delete_from_vultr = false; + + public bool $delete_from_digitalocean = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -35,6 +39,8 @@ class Delete extends Component if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); + $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -57,7 +63,11 @@ class Delete extends Component $this->delete_from_hetzner, $this->server->hetzner_server_id, $this->server->cloud_provider_token_id, - $this->server->team_id + $this->server->team_id, + $this->delete_from_vultr, + $this->server->vultr_instance_id, + $this->delete_from_digitalocean, + $this->server->digitalocean_droplet_id ); return redirectRoute($this, 'server.index'); @@ -87,6 +97,22 @@ class Delete extends Component ]; } + if ($this->server->vultr_instance_id) { + $checkboxes[] = [ + 'id' => 'delete_from_vultr', + 'label' => 'Also delete server from Vultr', + 'default_warning' => 'The actual server on Vultr will NOT be deleted.', + ]; + } + + if ($this->server->digitalocean_droplet_id) { + $checkboxes[] = [ + 'id' => 'delete_from_digitalocean', + 'label' => 'Also delete droplet from DigitalOcean', + 'default_warning' => 'The actual droplet on DigitalOcean will NOT be deleted.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index cd9cfcba6..73c256cbe 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -39,6 +39,7 @@ class Navbar extends Component return [ 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', + "echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus', ]; } @@ -203,6 +204,15 @@ class Navbar extends Component $this->server->load('settings'); } + public function refreshSentinelStatus($event = null): void + { + if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) { + return; + } + + $this->refreshServer(); + } + /** * Check if Traefik has any outdated version info (patch or minor upgrade). * This shows a warning indicator in the navbar. diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php new file mode 100644 index 000000000..7b0151851 --- /dev/null +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -0,0 +1,517 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function getListeners(): array + { + return [ + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedDigitalOceanSshKeyIds = []; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_image' => 'required', + 'selected_size' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedDigitalOceanSshKeyIds' => 'nullable|array', + 'selectedDigitalOceanSshKeyIds.*' => 'integer', + 'enable_ipv6' => 'required|boolean', + 'monitoring' => 'required|boolean', + 'show_cloud_init_script' => 'boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a DigitalOcean token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): mixed + { + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + + private function getDigitalOceanToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function nextStep() + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'digital-ocean', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep(): mixed + { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true); + } + + $this->current_step = 1; + + return null; + } + + public function loadDigitalOceanData(): void + { + $token = $this->getDigitalOceanToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $digitalOceanService = new DigitalOceanService($token); + + $this->regions = $digitalOceanService->getRegions(); + $this->sizes = $digitalOceanService->getSizes(); + $this->images = collect($digitalOceanService->getImages()) + ->sortBy(fn (array $image) => ($image['distribution'] ?? '').' '.($image['name'] ?? '')) + ->values() + ->toArray(); + $this->digitalOceanSshKeys = $digitalOceanService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + $this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message'); + $this->dispatch('error', $this->provider_data_error); + } + } + + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + + public function getAvailableSizesProperty(): array + { + if (! $this->selected_region) { + return $this->sizes; + } + + return collect($this->sizes) + ->filter(fn (array $size) => in_array($this->selected_region, $size['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getAvailableImagesProperty(): array + { + if (! $this->selected_region) { + return $this->images; + } + + return collect($this->images) + ->filter(fn (array $image) => in_array($this->selected_region, $image['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getSelectedDropletPriceProperty(): ?string + { + if (! $this->selected_size) { + return null; + } + + $size = collect($this->sizes)->firstWhere('slug', $this->selected_size); + if (! $size || ! isset($size['price_monthly'])) { + return null; + } + + return '$'.number_format((float) $size['price_monthly'], 2); + } + + public function updatedSelectedRegion(): void + { + $this->selected_size = null; + $this->selected_image = null; + } + + public function updatedSelectedSize(): void + { + $this->selected_image = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; + } + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + + public function getAdvancedDigitalOceanOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedDigitalOceanSshKeyIds) > 0) { + $summary[] = count($this->selectedDigitalOceanSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedDigitalOceanSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv4 only'; + } + + if (! $this->monitoring) { + $summary[] = 'Monitoring off'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; + } + + /** + * @return array{droplet: array, ip: string|null} + */ + private function createDigitalOceanDroplet(string $token): array + { + $digitalOceanService = new DigitalOceanService($token); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + $sshKeyId = null; + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + $sshKeyId = (int) $key['id']; + break; + } + } + + if (! $sshKeyId) { + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + $sshKeyId = (int) $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $this->selectedDigitalOceanSshKeyIds + ))); + + $params = [ + 'name' => strtolower(trim($this->server_name)), + 'region' => $this->selected_region, + 'size' => $this->selected_size, + 'image' => $this->selected_image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $this->enable_ipv6, + 'monitoring' => $this->monitoring, + ]; + + if (! empty($this->cloud_init_script)) { + $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, + ]; + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken()); + $droplet = $result['droplet']; + $ipAddress = $result['ip']; + + if (! $ipAddress) { + throw new \Exception('No public IP address available for the new droplet.'); + } + + $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, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + 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 9ae065d83..5ef88acee 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -12,6 +12,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Locked; @@ -37,6 +38,8 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + // Step 2: Server configuration public array $locations = []; @@ -46,6 +49,10 @@ class ByHetzner extends Component public array $hetznerSshKeys = []; + public array $hetznerFirewalls = []; + + public array $hetznerNetworks = []; + public ?string $selected_location = null; public ?int $selected_image = null; @@ -54,16 +61,26 @@ class ByHetzner extends Component public array $selectedHetznerSshKeyIds = []; + public array $selectedHetznerFirewallIds = []; + + public array $selectedHetznerNetworkIds = []; + public string $server_name = ''; public ?int $private_key_id = null; public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv4 = true; public bool $enable_ipv6 = true; + public bool $enable_backups = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -77,11 +94,12 @@ class ByHetzner extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -89,6 +107,11 @@ class ByHetzner extends Component if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -102,7 +125,7 @@ class ByHetzner extends Component public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', 'privateKeyCreated' => 'handlePrivateKeyCreated', 'modalClosed' => 'resetSelection', ]; @@ -112,10 +135,15 @@ class ByHetzner extends Component { $this->selected_token_id = null; $this->current_step = 1; + $this->enable_backups = false; $this->cloud_init_script = null; $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; } public function loadTokens() @@ -164,8 +192,14 @@ class ByHetzner extends Component 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, 'selectedHetznerSshKeyIds' => 'nullable|array', 'selectedHetznerSshKeyIds.*' => 'integer', + 'selectedHetznerFirewallIds' => 'nullable|array', + 'selectedHetznerFirewallIds.*' => 'integer', + 'selectedHetznerNetworkIds' => 'nullable|array', + 'selectedHetznerNetworkIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'enable_backups' => 'required|boolean', + 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -184,9 +218,27 @@ class ByHetzner extends Component ]; } - public function selectToken(int $tokenId) + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function validateHetznerToken(string $token): bool @@ -221,17 +273,20 @@ class ByHetzner extends Component ]); try { - $hetznerToken = $this->getHetznerToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $hetznerToken) { - return $this->dispatch('error', 'Please select a valid Hetzner token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hetzner', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - // Load Hetzner data - $this->loadHetznerData($hetznerToken); - - // Move to step 2 + // Move to step 2; provider data is loaded after initial render via wire:init. $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -239,12 +294,29 @@ class ByHetzner extends Component public function previousStep() { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true); + } + $this->current_step = 1; } - private function loadHetznerData(string $token) + public function loadHetznerData(): void { + $token = $this->getHetznerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hetzner token.'); + + return; + } + $this->loading_data = true; + $this->provider_data_error = null; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; try { $hetznerService = new HetznerService($token); @@ -274,13 +346,33 @@ class ByHetzner extends Component ->toArray(); // Load SSH keys from Hetzner $this->hetznerSshKeys = $hetznerService->getSshKeys(); + $this->hetznerFirewalls = collect($hetznerService->getFirewalls()) + ->sortBy('name') + ->values() + ->toArray(); + $this->hetznerNetworks = collect($hetznerService->getNetworks()) + ->sortBy('name') + ->values() + ->toArray(); $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); @@ -349,6 +441,37 @@ class ByHetzner extends Component return $filtered; } + public function getAvailableNetworksProperty(): array + { + $attachableNetworks = collect($this->hetznerNetworks) + ->filter(function (array $network) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true); + }); + }); + + if (! $this->selected_location) { + return $attachableNetworks->values()->toArray(); + } + + $location = collect($this->locations)->firstWhere('name', $this->selected_location); + $networkZone = $location['network_zone'] ?? null; + + if (! $networkZone) { + return $attachableNetworks->values()->toArray(); + } + + return $attachableNetworks + ->filter(function (array $network) use ($networkZone) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true) + && ($subnet['network_zone'] ?? null) === $networkZone; + }); + }) + ->values() + ->toArray(); + } + public function getSelectedServerPriceProperty(): ?string { if (! $this->selected_server_type) { @@ -366,11 +489,74 @@ class ByHetzner extends Component return '€'.number_format($price, 2); } + public function getSelectedServerBackupSurchargeProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = (float) $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price * 0.2, 2); + } + + public function getAdvancedHetznerOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedHetznerSshKeyIds) > 0) { + $summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds)); + } + + if (count($this->selectedHetznerFirewallIds) > 0) { + $summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds)); + } + + if (count($this->selectedHetznerNetworkIds) > 0) { + $summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds)); + } + + if ($this->enable_backups) { + $summary[] = 'Backups on'; + } + + if (! $this->enable_ipv4 || ! $this->enable_ipv6) { + $summary[] = collect([ + $this->enable_ipv4 ? 'IPv4' : null, + $this->enable_ipv6 ? 'IPv6' : null, + ])->filter()->join(' + ') ?: 'No public IP'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes $this->selected_server_type = null; $this->selected_image = null; + + $this->selectedHetznerNetworkIds = array_values(array_filter( + $this->selectedHetznerNetworkIds, + function (int $selectedNetworkId): bool { + return collect($this->availableNetworks)->contains('id', $selectedNetworkId); + } + )); } public function updatedSelectedServerType($value) @@ -390,6 +576,14 @@ class ByHetzner extends Component $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -399,12 +593,11 @@ class ByHetzner extends Component $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; } - private function createHetznerServer(string $token): array + private function createHetznerServer(HetznerService $hetznerService): array { - $hetznerService = new HetznerService($token); - // Get the private key and extract public key $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); @@ -458,6 +651,18 @@ class ByHetzner extends Component ], ]; + $firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($this->selectedHetznerNetworkIds)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($this->cloud_init_script)) { $params['user_data'] = $this->cloud_init_script; @@ -473,6 +678,13 @@ class ByHetzner extends Component { $this->validate(); + if (! $this->enable_ipv4 && ! $this->enable_ipv6) { + $this->addError('enable_ipv4', 'Enable at least one public IP protocol.'); + $this->addError('enable_ipv6', 'Enable at least one public IP protocol.'); + + return null; + } + try { $this->authorize('create', Server::class); @@ -492,9 +704,10 @@ class ByHetzner extends Component } $hetznerToken = $this->getHetznerToken(); + $hetznerService = new HetznerService($hetznerToken); // Create server on Hetzner - $hetznerServer = $this->createHetznerServer($hetznerToken); + $hetznerServer = $this->createHetznerServer($hetznerService); // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; @@ -524,6 +737,14 @@ class ByHetzner extends Component $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index f5ea2ae80..a85306f6b 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use App\Rules\ValidServerIp; @@ -84,11 +85,51 @@ class ByIp extends Component ]); } + public function getListeners(): array + { + return [ + 'privateKeyCreated' => 'handlePrivateKeyCreated', + ]; + } + public function setPrivateKey(string $private_key_id) { $this->private_key_id = $private_key_id; } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->handlePrivateKeyCreated($privateKey->id); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + public function instantSave() { // $this->dispatch('success', 'Application settings updated!'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php new file mode 100644 index 000000000..9e6bc3cf2 --- /dev/null +++ b/app/Livewire/Server/New/ByVultr.php @@ -0,0 +1,516 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_plan' => 'required|string', + 'selected_os_id' => 'required|integer', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedVultrSshKeyIds' => 'nullable|array', + 'selectedVultrSshKeyIds.*' => 'string', + 'enable_ipv6' => 'required|boolean', + 'disable_public_ipv4' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Vultr token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): mixed + { + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'vultr', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + public function previousStep(): mixed + { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true); + } + + $this->current_step = 1; + + return null; + } + + public function updatedSelectedRegion(): void + { + $this->selected_plan = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + + public function getAvailablePlansProperty(): array + { + if (! $this->selected_region) { + return $this->plans; + } + + return collect($this->plans) + ->filter(function ($plan) { + $locations = $plan['locations'] ?? []; + + return empty($locations) || in_array($this->selected_region, $locations); + }) + ->values() + ->toArray(); + } + + public function getSelectedServerPriceProperty(): ?string + { + if (! $this->selected_plan) { + return null; + } + + $plan = collect($this->plans)->firstWhere('id', $this->selected_plan); + $monthlyCost = $plan['monthly_cost'] ?? null; + + if ($monthlyCost === null) { + return null; + } + + return '$'.number_format((float) $monthlyCost, 2); + } + + public function getAdvancedVultrOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedVultrSshKeyIds) > 0) { + $summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv6 disabled'; + } + + if ($this->disable_public_ipv4) { + $summary[] = 'Public IPv4 disabled'; + } + + if (! empty($this->cloud_init_script)) { + $summary[] = 'cloud-init'; + } + + return $summary; + } + + private function getVultrToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function loadVultrData(): void + { + $token = $this->getVultrToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Vultr token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $vultrService = new VultrService($token); + + $this->regions = collect($vultrService->getRegions()) + ->sortBy('id') + ->values() + ->toArray(); + + $this->plans = collect($vultrService->getPlans()) + ->sortBy('monthly_cost') + ->values() + ->toArray(); + + $this->operatingSystems = collect($vultrService->getOperatingSystems()) + ->sortBy('name') + ->values() + ->toArray(); + + $this->vultrSshKeys = $vultrService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + $this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error'); + $this->dispatch('error', $this->provider_data_error); + } + } + + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + + private function createVultrServer(string $token): array + { + $vultrService = new VultrService($token); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $this->selectedVultrSshKeyIds))); + $normalizedServerName = strtolower(trim($this->server_name)); + + $params = [ + 'region' => $this->selected_region, + 'plan' => $this->selected_plan, + 'os_id' => $this->selected_os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $this->enable_ipv6, + 'disable_public_ipv4' => $this->disable_public_ipv4, + ]; + + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + return $vultrService->createInstance($params); + } + + public function submit(): mixed + { + $this->validate(); + if (! $this->hasValidPublicNetworkConfiguration()) { + return null; + } + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $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'; + + $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->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($this->from_onboarding) { + currentTeam()->update([ + 'show_boarding' => false, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-vultr'); + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } + + private function hasValidPublicNetworkConfiguration(): bool + { + if (! $this->disable_public_ipv4 || $this->enable_ipv6) { + return true; + } + + $this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.'); + + return false; + } +} diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 810b95ed4..54f3436dc 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -55,6 +55,33 @@ class Show extends Component } } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function checkConnection() { try { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 433f544ae..15af859c9 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -8,7 +8,9 @@ use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; use App\Rules\ValidServerIp; +use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\VultrService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -75,8 +77,16 @@ class Show extends Component public ?string $hetznerServerStatus = null; + public ?string $vultrInstanceStatus = null; + + public ?string $digitalOceanDropletStatus = null; + public bool $hetznerServerManuallyStarted = false; + public bool $vultrInstanceManuallyStarted = false; + + public bool $digitalOceanDropletManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -92,6 +102,30 @@ class Show extends Component public bool $hetznerNoMatchFound = false; + public Collection $availableVultrTokens; + + public ?int $selectedVultrTokenId = null; + + public ?string $manualVultrInstanceId = null; + + public ?array $matchedVultrInstance = null; + + public ?string $vultrSearchError = null; + + public bool $vultrNoMatchFound = false; + + public Collection $availableDigitalOceanTokens; + + public ?int $selectedDigitalOceanTokenId = null; + + public ?string $manualDigitalOceanDropletId = null; + + public ?array $matchedDigitalOceanDroplet = null; + + public ?string $digitalOceanSearchError = null; + + public bool $digitalOceanNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -173,10 +207,14 @@ class Show extends Component } // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; + $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; $this->isValidating = $this->server->is_validating ?? false; - // Load Hetzner tokens for linking + // Load cloud provider tokens for linking $this->loadHetznerTokens(); + $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -289,6 +327,22 @@ class Show extends Component { try { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->ip = $this->server->ip; + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $message = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->dispatch('error', $message); + + return; + } + } + $this->validationLogs = $this->server->validation_logs = null; $this->server->save(); $this->dispatch('init', $install); @@ -458,6 +512,49 @@ class Show extends Component } } + public function checkVultrInstanceStatus(bool $manual = false) + { + try { + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $this->vultrInstanceStatus = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function checkDigitalOceanDropletStatus(bool $manual = false) + { + try { + $this->authorize('view', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $this->digitalOceanDropletStatus = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Droplet status refreshed: '.ucfirst($this->digitalOceanDropletStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -477,6 +574,8 @@ class Show extends Component // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); + $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -504,6 +603,49 @@ class Show extends Component } } + public function startVultrInstance() + { + try { + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $vultrService = new VultrService($this->server->cloudProviderToken->token); + $vultrService->startInstance($this->server->vultr_instance_id); + + $this->vultrInstanceStatus = 'starting'; + $this->server->update(['vultr_instance_status' => 'starting']); + $this->vultrInstanceManuallyStarted = true; + $this->dispatch('success', 'Vultr instance is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function startDigitalOceanDroplet() + { + try { + $this->authorize('update', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($this->server->cloudProviderToken->token); + $digitalOceanService->powerOnDroplet((int) $this->server->digitalocean_droplet_id); + + $this->digitalOceanDropletStatus = 'new'; + $this->server->update(['digitalocean_droplet_status' => 'new']); + $this->digitalOceanDropletManuallyStarted = true; + $this->dispatch('success', 'DigitalOcean droplet is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -537,6 +679,20 @@ class Show extends Component ->get(); } + public function loadVultrTokens(): void + { + $this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + + public function loadDigitalOceanTokens(): void + { + $this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + #[Computed] public function limaStartCommand(): ?string { @@ -672,6 +828,263 @@ class Show extends Component $this->hetznerSearchError = null; $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function searchDigitalOceanDroplet(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $matched = $digitalOceanService->findDropletByIp($this->server->ip); + + if ($matched) { + $this->matchedDigitalOceanDroplet = $matched; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to search DigitalOcean droplets: '.$e->getMessage(); + } + } + + public function searchDigitalOceanDropletById(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token first.'; + + return; + } + + if (! $this->manualDigitalOceanDropletId) { + $this->digitalOceanSearchError = 'Please enter a DigitalOcean Droplet ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->manualDigitalOceanDropletId); + + if (! empty($dropletData)) { + $this->matchedDigitalOceanDroplet = $dropletData; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to fetch DigitalOcean droplet: '.$e->getMessage(); + } + } + + public function linkToDigitalOcean() + { + if (! $this->matchedDigitalOceanDroplet) { + $this->dispatch('error', 'No DigitalOcean droplet selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->matchedDigitalOceanDroplet['id']); + + if (empty($dropletData)) { + $this->dispatch('error', 'Could not find DigitalOcean droplet with ID: '.$this->matchedDigitalOceanDroplet['id']); + + return; + } + + $ip = $digitalOceanService->getPublicIpAddress($dropletData); + $updates = [ + 'cloud_provider_token_id' => $this->selectedDigitalOceanTokenId, + 'digitalocean_droplet_id' => $this->matchedDigitalOceanDroplet['id'], + 'digitalocean_droplet_status' => $dropletData['status'] ?? null, + ]; + + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->digitalOceanDropletStatus = $dropletData['status'] ?? null; + + $this->matchedDigitalOceanDroplet = null; + $this->selectedDigitalOceanTokenId = null; + $this->manualDigitalOceanDropletId = null; + $this->digitalOceanNoMatchFound = false; + $this->digitalOceanSearchError = null; + + $this->dispatch('success', 'Server successfully linked to DigitalOcean!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function searchVultrInstance(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $matched = $vultrService->findInstanceByIp($this->server->ip); + + if ($matched) { + $this->matchedVultrInstance = $matched; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to search Vultr instances: '.$e->getMessage(); + } + } + + public function searchVultrInstanceById(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token first.'; + + return; + } + + if (! $this->manualVultrInstanceId) { + $this->vultrSearchError = 'Please enter a Vultr Instance ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->manualVultrInstanceId); + + if (! empty($instanceData)) { + $this->matchedVultrInstance = $instanceData; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to fetch Vultr instance: '.$e->getMessage(); + } + } + + public function linkToVultr() + { + if (! $this->matchedVultrInstance) { + $this->dispatch('error', 'No Vultr instance selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->matchedVultrInstance['id']); + + if (empty($instanceData)) { + $this->dispatch('error', 'Could not find Vultr instance with ID: '.$this->matchedVultrInstance['id']); + + return; + } + + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedVultrTokenId, + 'vultr_instance_id' => $this->matchedVultrInstance['id'], + 'vultr_instance_status' => $instanceData['status'] ?? null, + ]); + + $this->vultrInstanceStatus = $instanceData['status'] ?? null; + + $this->matchedVultrInstance = null; + $this->selectedVultrTokenId = null; + $this->manualVultrInstanceId = null; + $this->vultrNoMatchFound = false; + $this->vultrSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Vultr!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index afcc918a6..c7181ebcf 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -92,6 +92,38 @@ class ValidateAndInstall extends Component { try { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + + if ($this->server->digitalocean_droplet_id) { + $status = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 682333aa6..a24ed9ce3 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -9,6 +9,7 @@ use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Change extends Component @@ -78,11 +79,13 @@ class Change extends Component public string $activeTab = 'general'; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + protected function rules(): array { return [ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'customUser' => 'required|string', @@ -103,6 +106,19 @@ class Change extends Component ]; } + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl) + || $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); + } + } + public function boot() { if ($this->github_app) { @@ -119,6 +135,11 @@ class Change extends Component { if ($toModel) { // Sync TO model (before save) + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = filled($this->apiUrl) + ? $this->apiUrl + : githubApiUrlFromHtmlUrl($this->htmlUrl); + $this->github_app->name = $this->name; $this->github_app->organization = $this->organization; $this->github_app->api_url = $this->apiUrl; @@ -294,11 +315,14 @@ class Change extends Component public function getGithubAppNameUpdatePath() { - if (str($this->github_app->organization)->isNotEmpty()) { - return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; + $name = encodeGithubPathSegment($this->github_app->name); + $organization = normalizeGithubOrganization($this->github_app->organization); + + if (filled($organization)) { + return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}"; } - return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; + return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}"; } public function updateGithubAppName() @@ -341,11 +365,17 @@ class Change extends Component $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = filled($this->apiUrl) + ? $this->apiUrl + : githubApiUrlFromHtmlUrl($this->htmlUrl); $this->validate(); $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index ec2ba3f08..6a5bf6e60 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -5,6 +5,7 @@ namespace App\Livewire\Source\Github; use App\Models\GithubApp; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Create extends Component @@ -25,19 +26,39 @@ class Create extends Component public bool $is_system_wide = false; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + public function mount() { $this->name = substr(generate_random_name(), 0, 30); } + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url) + || $this->api_url === githubApiUrlFromHtmlUrl($this->html_url); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->api_url = githubApiUrlFromHtmlUrl($this->html_url); + } + } + public function createGitHubApp() { try { $this->authorize('createAnyResource'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->api_url = filled($this->api_url) + ? $this->api_url + : githubApiUrlFromHtmlUrl($this->html_url); + $this->validate([ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'required|string', @@ -60,6 +81,8 @@ class Create extends Component } return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 10d87ae92..1473d8994 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Application.php b/app/Models/Application.php index 2c408483e..d46e4366b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -176,11 +176,8 @@ class Application extends BaseModel 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', - 'docker_compose_pr_location', 'docker_compose', - 'docker_compose_pr', 'docker_compose_raw', - 'docker_compose_pr_raw', 'docker_compose_domains', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', @@ -218,6 +215,24 @@ class Application extends BaseModel protected $appends = ['server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal serializers (deployment + * job, compose generation) must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ]; + protected function casts(): array { return [ @@ -1266,9 +1281,9 @@ class Application extends BaseModel { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } return md5($newConfigHash); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 53fb8337f..ee190532c 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -84,6 +84,7 @@ class ApplicationDeploymentQueue extends Model * @var array */ protected $hidden = [ + 'logs', 'configuration_snapshot', 'configuration_diff', ]; diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 2c78cc582..671c5e76c 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudInitScript extends Model +class CloudInitScript extends BaseModel { protected $fillable = [ 'team_id', @@ -12,6 +10,10 @@ class CloudInitScript extends Model 'script', ]; + protected $hidden = [ + 'script', + ]; + protected function casts(): array { return [ diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 35452553b..ab9897f9a 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -13,6 +13,11 @@ class CloudProviderToken extends BaseModel 'provider', 'token', 'name', + 'description', + ]; + + protected $hidden = [ + 'token', ]; protected $casts = [ diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index e86598126..135c921f6 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model 'discord_ping_enabled', ]; + protected $hidden = [ + 'discord_webhook_url', + ]; + protected $casts = [ 'discord_enabled' => 'boolean', 'discord_webhook_url' => 'encrypted', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 1277e45d9..7368bafbf 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model 'traefik_outdated_email_notifications', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 55830f889..1364d874a 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -47,6 +47,11 @@ class Environment extends BaseModel return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId) + { + return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index e346e59d1..89188b31b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -80,6 +80,16 @@ class EnvironmentVariable extends BaseModel protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'value', + 'real_value', + ]; + protected static function booted() { static::created(function (ModelsEnvironmentVariable $environment_variable) { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 57d3e6ae6..933740403 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -50,6 +50,17 @@ class InstanceSettings extends Model 'webhook_allow_localhost', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + 'sentinel_token', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4d18d4ca2..968e6c3d0 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -25,6 +25,10 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled' => 'boolean', ]; + protected $hidden = [ + 'content', + ]; + use HasFactory; protected $fillable = [ diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 08e08d85b..e7999134a 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -13,6 +13,10 @@ class OauthSetting extends Model protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $hidden = [ + 'client_secret', + ]; + protected function clientSecret(): Attribute { return Attribute::make( diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bf42f21c7..3f72642a5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -42,6 +42,10 @@ class PrivateKey extends BaseModel 'fingerprint', ]; + protected $hidden = [ + 'private_key', + ]; + protected $casts = [ 'private_key' => 'encrypted', ]; @@ -287,7 +291,7 @@ class PrivateKey extends BaseModel public function getKeyLocation() { - return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); } public function updatePrivateKey(array $data) diff --git a/app/Models/Project.php b/app/Models/Project.php index b47e7cf04..5c821b017 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Collection; use OpenApi\Attributes as OA; #[OA\Schema( @@ -155,9 +156,16 @@ class Project extends BaseModel $this->services()->count() == 0; } - public function databases() + public function databases(array $with = []): Collection { - return $this->postgresqls()->get()->merge($this->redis()->get())->merge($this->mongodbs()->get())->merge($this->mysqls()->get())->merge($this->mariadbs()->get())->merge($this->keydbs()->get())->merge($this->dragonflies()->get())->merge($this->clickhouses()->get()); + return $this->postgresqls()->with($with)->get() + ->merge($this->redis()->with($with)->get()) + ->merge($this->mongodbs()->with($with)->get()) + ->merge($this->mysqls()->with($with)->get()) + ->merge($this->mariadbs()->with($with)->get()) + ->merge($this->keydbs()->with($with)->get()) + ->merge($this->dragonflies()->with($with)->get()) + ->merge($this->clickhouses()->with($with)->get()); } public function navigateTo() diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 5ad617ad6..dd0d81cc0 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model 'traefik_outdated_pushover_notifications', ]; + protected $hidden = [ + 'pushover_user_key', + 'pushover_api_token', + ]; + protected $casts = [ 'pushover_enabled' => 'boolean', 'pushover_user_key' => 'encrypted', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index fe08984ed..70703cd52 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -32,6 +32,11 @@ class S3Storage extends BaseModel 'unusable_email_sent', ]; + protected $hidden = [ + 'key', + 'secret', + ]; + protected $casts = [ 'is_usable' => 'boolean', 'key' => 'encrypted', diff --git a/app/Models/Server.php b/app/Models/Server.php index f6fc39df9..4bf57207f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -17,6 +17,8 @@ use App\Livewire\Server\Proxy; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Services\DigitalOceanService; +use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; @@ -254,6 +256,16 @@ class Server extends BaseModel 'force_disabled' => 'boolean', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]; + protected $schemalessAttributes = [ 'proxy', ]; @@ -269,7 +281,12 @@ class Server extends BaseModel 'team_id', 'hetzner_server_id', 'hetzner_server_status', + 'vultr_instance_id', + 'vultr_instance_status', + 'digitalocean_droplet_id', + 'digitalocean_droplet_status', 'is_validating', + 'validation_logs', 'detected_traefik_version', 'traefik_outdated_info', 'server_metadata', @@ -290,6 +307,96 @@ class Server extends BaseModel return 'server'; } + public function refreshVultrState(): ?string + { + if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { + return null; + } + + $vultrService = new VultrService($this->cloudProviderToken->token); + try { + $instance = $vultrService->getInstance($this->vultr_instance_id); + } catch (\Throwable $e) { + if ((int) $e->getCode() !== 404) { + throw $e; + } + + if ($this->vultr_instance_status !== 'deleted') { + $this->update(['vultr_instance_status' => 'deleted']); + $this->forceFill(['vultr_instance_status' => 'deleted']); + } + + return 'deleted'; + } + + $status = ($instance['power_status'] ?? null) === 'stopped' + ? 'stopped' + : ($instance['status'] ?? null); + $publicIp = $vultrService->getPublicIp($instance); + + $updates = []; + 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); + } + + return $status; + } + + public function refreshDigitalOceanState(): ?string + { + if (! $this->digitalocean_droplet_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'digitalocean') { + return $this->digitalocean_droplet_status; + } + + $digitalOceanService = new DigitalOceanService($this->cloudProviderToken->token); + + try { + $droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id); + } catch (RequestException $e) { + if ($e->response?->status() === 404) { + $this->update(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } catch (\Throwable $e) { + if ((int) $e->getCode() === 404) { + $this->update(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } + + if (empty($droplet)) { + return $this->digitalocean_droplet_status; + } + + $status = $droplet['status'] ?? null; + $ip = $digitalOceanService->getPublicIpAddress($droplet); + + $updates = ['digitalocean_droplet_status' => $status]; + if ($ip && $ip !== $this->ip) { + $updates['ip'] = $ip; + } + + $this->update($updates); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 79f62f4b7..e96aab4a3 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -114,6 +114,20 @@ class ServerSetting extends Model 'connection_timeout' => 'integer', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]; + protected static function booted() { static::creating(function ($setting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 98af0472f..89438053d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -66,6 +66,17 @@ class Service extends BaseModel protected $appends = ['server_status', 'status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal compose generators + * must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'docker_compose', + 'docker_compose_raw', + ]; + protected static function booted() { static::creating(function ($service) { @@ -94,7 +105,7 @@ class Service extends BaseModel $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); $newConfigHash = $images.$domains.$images.$storages; - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index eadc33ec2..8bb241240 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model 'version', ]; + protected $hidden = [ + 'value', + ]; + protected $casts = [ 'key' => 'string', 'value' => 'encrypted', diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index d4f125fb5..62603685e 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model 'traefik_outdated_slack_notifications', ]; + protected $hidden = [ + 'slack_webhook_url', + ]; + protected $casts = [ 'slack_enabled' => 'boolean', 'slack_webhook_url' => 'encrypted', diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php index eb2175d44..2311cea72 100644 --- a/app/Models/SslCertificate.php +++ b/app/Models/SslCertificate.php @@ -20,6 +20,10 @@ class SslCertificate extends Model 'is_ca_certificate', ]; + protected $hidden = [ + 'ssl_private_key', + ]; + protected $casts = [ 'ssl_certificate' => 'encrypted', 'ssl_private_key' => 'encrypted', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index b104be642..9db5f21b7 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -54,6 +54,17 @@ class StandaloneClickhouse extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ class StandaloneClickhouse extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 2232ec772..769d9f00c 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -53,6 +53,17 @@ class StandaloneDragonfly extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -122,7 +133,7 @@ class StandaloneDragonfly extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index b9f9f765b..15a1fe2f8 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -54,6 +54,17 @@ class StandaloneKeydb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ class StandaloneKeydb extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index cd94b6c9b..378d36395 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -57,6 +57,18 @@ class StandaloneMariadb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -126,7 +138,7 @@ class StandaloneMariadb extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7d2ffbd74..1010ca5f3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -57,6 +57,17 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -132,7 +143,7 @@ class StandaloneMongodb extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index f752312d3..90828bf01 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -58,6 +58,18 @@ class StandaloneMysql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -128,7 +140,7 @@ class StandaloneMysql extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 04d2291b3..e7db81285 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -60,6 +60,18 @@ class StandalonePostgresql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -170,7 +182,7 @@ class StandalonePostgresql extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index efb0254fb..326261190 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -53,6 +53,17 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'redis_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -127,7 +138,7 @@ class StandaloneRedis extends BaseModel { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index e6fbd3a06..d5cccabd8 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,7 +3,19 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Support\Facades\DB; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Tag model', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string'), + ] +)] class Tag extends BaseModel { use HasSafeStringAttribute; @@ -23,6 +35,13 @@ class Tag extends BaseModel return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } + public function deleteIfOrphaned(): void + { + if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { + $this->delete(); + } + } + public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 4930f45d4..8c644f9bc 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_traefik_outdated_thread_id', ]; + protected $hidden = [ + 'telegram_token', + 'telegram_chat_id', + 'telegram_notifications_deployment_success_thread_id', + 'telegram_notifications_deployment_failure_thread_id', + 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_backup_success_thread_id', + 'telegram_notifications_backup_failure_thread_id', + 'telegram_notifications_scheduled_task_success_thread_id', + 'telegram_notifications_scheduled_task_failure_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', + 'telegram_notifications_server_disk_usage_thread_id', + 'telegram_notifications_server_reachable_thread_id', + 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', + 'telegram_notifications_traefik_outdated_thread_id', + ]; + protected $casts = [ 'telegram_enabled' => 'boolean', 'telegram_token' => 'encrypted', @@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted', - 'telegram_notifications_docker_cleanup_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted', 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 731006181..c6a81b50a 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model 'traefik_outdated_webhook_notifications', ]; + protected $hidden = [ + 'webhook_url', + ]; + protected function casts(): array { return [ diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index c730ab0c6..491b9e424 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -32,6 +32,14 @@ class ServiceApplicationPolicy return Gate::allows('update', $serviceApplication->service); } + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceApplication $serviceApplication): bool + { + return Gate::allows('deploy', $serviceApplication->service); + } + /** * Determine whether the user can delete the model. */ diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 85f38b967..1b201fb3f 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; +use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -82,7 +83,7 @@ class FortifyServiceProvider extends ServiceProvider $user->save(); // Check if user has a pending invitation they haven't accepted yet - $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + $invitation = TeamInvitation::whereEmail($email)->first(); if ($invitation && $invitation->isValid()) { // User is logging in for the first time after being invited // Attach them to the invited team if not already attached @@ -130,7 +131,16 @@ class FortifyServiceProvider extends ServiceProvider // Use real client IP (not spoofable forwarded headers) $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - return Limit::perMinute(5)->by($realIp); + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; }); RateLimiter::for('login', function (Request $request) { diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php index 6960a8f1b..8eedf0920 100644 --- a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php +++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php @@ -9,7 +9,7 @@ trait SummarizesDiffText * worth expanding. Kept as one constant so the snapshot summary and the * differ's expand decision never drift apart. */ - private const SINGLE_LINE_LIMIT = 120; + private const SINGLE_LINE_LIMIT = 40; /** * Returns the value only when it is worth expanding (multi-line or longer diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php new file mode 100644 index 000000000..c8292e864 --- /dev/null +++ b/app/Services/DigitalOceanService.php @@ -0,0 +1,221 @@ +token) + ->acceptJson() + ->timeout(30) + ->connectTimeout(10) + ->retry(3, function (int $attempt, \Exception $exception) { + if ($exception instanceof RequestException && $exception->response?->status() === 429) { + $resetTime = $exception->response->header('RateLimit-Reset'); + + if ($resetTime) { + return min(max(0, (int) $resetTime - time()), 60) * 1000; + } + } + + return $attempt * 100; + }) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + + throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status()); + } + + return $response->json() ?? []; + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $response = $this->request('get', $endpoint, array_merge($data, [ + 'page' => $page, + 'per_page' => 50, + ])); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $hasNextPage = filled(data_get($response, 'links.pages.next')); + $page++; + } while ($hasNextPage); + + return $allResults; + } + + public function getAccount(): array + { + return $this->request('get', '/account')['account'] ?? []; + } + + public function getRegions(): array + { + return array_values(array_filter( + $this->requestPaginated('/regions', 'regions'), + fn (array $region) => ($region['available'] ?? true) === true + )); + } + + public function getSizes(): array + { + return array_values(array_filter( + $this->requestPaginated('/sizes', 'sizes'), + fn (array $size) => ($size['available'] ?? true) === true + )); + } + + public function getImages(): array + { + return array_values(array_filter( + $this->requestPaginated('/images', 'images', ['type' => 'distribution']), + fn (array $image) => ($image['public'] ?? true) === true + )); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/account/keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/account/keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createDroplet(array $params): array + { + $response = $this->request('post', '/droplets', $params); + + return $response['droplet'] ?? []; + } + + public function getDroplet(int $dropletId): array + { + $response = $this->request('get', $this->dropletEndpoint($dropletId)); + + return $response['droplet'] ?? []; + } + + public function waitForPublicIp(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true, int $attempts = 30, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6) || empty($droplet['id'])) { + return $droplet; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $droplet = $this->getDroplet((int) $droplet['id']); + + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6)) { + return $droplet; + } + } + + return $droplet; + } + + public function powerOnDroplet(int $dropletId): array + { + $response = $this->request('post', $this->dropletEndpoint($dropletId).'/actions', [ + 'type' => 'power_on', + ]); + + return $response['action'] ?? []; + } + + public function deleteDroplet(int $dropletId): void + { + $this->request('delete', $this->dropletEndpoint($dropletId)); + } + + public function getDroplets(): array + { + return $this->requestPaginated('/droplets', 'droplets'); + } + + public function findDropletByIp(string $ip): ?array + { + foreach ($this->getDroplets() as $droplet) { + if ($this->dropletHasIp($droplet, $ip)) { + return $droplet; + } + } + + return null; + } + + public function getPublicIpAddress(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true): ?string + { + if ($enableIpv4) { + foreach (data_get($droplet, 'networks.v4', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + if ($enableIpv6) { + foreach (data_get($droplet, 'networks.v6', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + return null; + } + + private function dropletEndpoint(int $dropletId): string + { + return '/droplets/'.$dropletId; + } + + private function dropletHasIp(array $droplet, string $ip): bool + { + foreach (['v4', 'v6'] as $version) { + foreach (data_get($droplet, "networks.{$version}", []) as $network) { + if (($network['ip_address'] ?? null) === $ip) { + return true; + } + } + } + + return false; + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 099aecf2e..27dbc4a83 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -118,6 +118,16 @@ class HetznerService return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); } + public function getFirewalls(): array + { + return $this->requestPaginated('get', '/firewalls', 'firewalls'); + } + + public function getNetworks(): array + { + return $this->requestPaginated('get', '/networks', 'networks'); + } + public function uploadSshKey(string $name, string $publicKey): array { $response = $this->request('post', '/ssh_keys', [ @@ -136,6 +146,13 @@ class HetznerService return $response['server'] ?? []; } + public function enableServerBackup(int $serverId): array + { + $response = $this->request('post', "/servers/{$serverId}/actions/enable_backup"); + + return $response['action'] ?? []; + } + public function getServer(int $serverId): array { $response = $this->request('get', "/servers/{$serverId}"); diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php new file mode 100644 index 000000000..0e335d3e0 --- /dev/null +++ b/app/Services/VultrService.php @@ -0,0 +1,191 @@ + 'Bearer '.$this->token, + ]) + ->timeout(30) + ->retry(3, fn (int $attempt) => $attempt * 100) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null + ); + } + + throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status()); + } + + return $response->json() ?? []; + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $cursor = null; + + do { + $query = $data; + $query['per_page'] = 100; + + if ($cursor !== null) { + $query['cursor'] = $cursor; + } + + $response = $this->request('get', $endpoint, $query); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $next = $response['meta']['links']['next'] ?? null; + $cursor = $this->cursorFromNextLink($next); + } while ($cursor !== null); + + return $allResults; + } + + private function cursorFromNextLink(?string $next): ?string + { + if (blank($next)) { + return null; + } + + parse_str((string) parse_url($next, PHP_URL_QUERY), $query); + + return $query['cursor'] ?? null; + } + + public function getRegions(): array + { + return $this->requestPaginated('/regions', 'regions'); + } + + public function getPlans(): array + { + return $this->requestPaginated('/plans', 'plans'); + } + + public function getOperatingSystems(): array + { + return $this->requestPaginated('/os', 'os'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/ssh-keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh-keys', [ + 'name' => $name, + 'ssh_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createInstance(array $params): array + { + if (! empty($params['user_data'])) { + $params['user_data'] = base64_encode($params['user_data']); + } + + $response = $this->request('post', '/instances', $params); + + return $response['instance'] ?? []; + } + + public function waitForPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true, int $attempts = 6, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6) || empty($instance['id'])) { + return $instance; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $instance = $this->getInstance($instance['id']); + + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6)) { + return $instance; + } + } + + return $instance; + } + + public function getPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true): ?string + { + $ipv4 = $instance['main_ip'] ?? null; + if (! $disablePublicIpv4 && $this->isUsableIp($ipv4)) { + return $ipv4; + } + + $ipv6 = $instance['v6_main_ip'] ?? null; + if ($enableIpv6 && $this->isUsableIp($ipv6)) { + return $ipv6; + } + + return null; + } + + private function isUsableIp(?string $ip): bool + { + return ! blank($ip) && ! in_array($ip, ['0.0.0.0', '::'], true); + } + + public function getInstance(string $instanceId): array + { + $response = $this->request('get', $this->instanceEndpoint($instanceId)); + + return $response['instance'] ?? []; + } + + public function startInstance(string $instanceId): array + { + return $this->request('post', $this->instanceEndpoint($instanceId).'/start'); + } + + public function deleteInstance(string $instanceId): void + { + $this->request('delete', $this->instanceEndpoint($instanceId)); + } + + public function getInstances(): array + { + return $this->requestPaginated('/instances', 'instances'); + } + + public function findInstanceByIp(string $ip): ?array + { + foreach ($this->getInstances() as $instance) { + if (($instance['main_ip'] ?? null) === $ip || ($instance['v6_main_ip'] ?? null) === $ip) { + return $instance; + } + } + + return null; + } + + private function instanceEndpoint(string $instanceId): string + { + return '/instances/'.rawurlencode($instanceId); + } +} diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 000000000..cdeb75e58 --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, normalized: ?string} + */ + public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array + { + $errors = []; + + if ($urlValue === null || $urlValue === '') { + return ['errors' => [], 'normalized' => null]; + } + + $urls = str($urlValue) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn ($url) => trim((string) $url)) + ->filter(); + + foreach ($urls as $url) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + } + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; + } + + if (count($errors) > 0) { + return ['errors' => $errors, 'normalized' => null]; + } + + $normalized = $urls + ->map(fn ($u) => str($u)->lower()->value()) + ->unique() + ->filter(fn ($u) => filled($u)) + ->implode(','); + + return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null]; + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index f88f78c34..af75098b5 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -557,7 +557,7 @@ class ValidationPatterns continue; } - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; continue; diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 8511c87db..f03b36f0e 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -22,6 +22,7 @@ class Button extends Component public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public ?string $tooltip = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 200bc6856..e430e7364 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,10 +3,15 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\Environment; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; function getTeamIdFromToken() @@ -87,6 +92,20 @@ function serializeApiResponse($data) } } +/** + * Re-expose a model's `$hidden` sensitive fields when the current API request + * carries the `read:sensitive` or `root` token ability (set by the + * ApiSensitiveData middleware). + */ +function exposeSensitiveFields(Model $model): Model +{ + if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) { + $model->makeVisible($model->getHidden()); + } + + return $model; +} + function sharedDataApplications() { return [ @@ -97,6 +116,7 @@ function sharedDataApplications() 'is_spa' => 'boolean', 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', + 'is_preview_deployments_enabled' => 'boolean', 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => ValidationPatterns::applicationDomainRules(), 'redirect' => Rule::enum(RedirectTypes::class), @@ -156,6 +176,64 @@ function sharedDataApplications() ]; } +function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse +{ + + $validator = Validator::make($request->all(), [ + 'environment_uuid' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $extraFields = array_diff(array_keys($request->all()), ['environment_uuid']); + if (! empty($extraFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(), + ], 422); + } + + $newEnvironment = Environment::ownedByCurrentTeamAPI($teamId) + ->whereUuid($request->environment_uuid) + ->first(); + + if (! $newEnvironment) { + return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404); + } + + Gate::authorize('update', $newEnvironment); + + if ($resource->environment_id === $newEnvironment->id) { + return response()->json(['message' => "$resourceType is already in this environment."], 400); + } + + $oldEnvironment = $resource->environment()->with('project')->first(); + + $resource->update(['environment_id' => $newEnvironment->id]); + + auditLog('api.'.str($resourceType)->lower()->value().'.moved', [ + 'team_id' => $teamId, + 'resource_uuid' => $resource->uuid, + 'resource_type' => str($resourceType)->lower()->value(), + 'from_project_uuid' => $oldEnvironment?->project?->uuid, + 'from_environment_uuid' => $oldEnvironment?->uuid, + 'to_project_uuid' => $newEnvironment->project->uuid, + 'to_environment_uuid' => $newEnvironment->uuid, + ]); + + return response()->json([ + 'message' => "$resourceType moved successfully.", + 'uuid' => $resource->uuid, + 'project_uuid' => $newEnvironment->project->uuid, + 'environment_uuid' => $newEnvironment->uuid, + ]); +} + function validateIncomingRequest(Request $request) { // check if request is json @@ -198,10 +276,13 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('is_spa'); $request->offsetUnset('is_auto_deploy_enabled'); $request->offsetUnset('is_force_https_enabled'); + $request->offsetUnset('is_preview_deployments_enabled'); $request->offsetUnset('connect_to_docker_network'); $request->offsetUnset('force_domain_override'); $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('is_preserve_repository_enabled'); + $request->offsetUnset('include_source_commit_in_build'); $request->offsetUnset('docker_compose_raw'); + $request->offsetUnset('tags'); } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2f7cc95ef..0440ae352 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection +{ + return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name); +} + +function filterServiceSubContainersByName(Collection $containers, string $name): Collection +{ + return $containers->filter(function ($container) use ($name) { + $labels = data_get($container, 'Labels', []); + if (is_string($labels)) { + $labels = format_docker_labels_to_json($labels); + } + + return collect($labels)->get('coolify.name') === $name; + })->values(); +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); @@ -148,13 +178,18 @@ function executeInDocker(string $containerId, string $command) return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } -function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +function buildContainerStatusCommand(Server $server, string $container_id): string { if ($server->isSwarm()) { - $container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError); - } else { - $container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError); + return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' "; } + + return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id); +} + +function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +{ + $container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError); if (! $container) { return 'exited'; } @@ -1247,18 +1282,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function getContainerLogs(Server $server, string $container_id, int $lines = 100): string +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int { - if ($server->isSwarm()) { - $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id} 2>&1", - ], $server); - } else { - $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id} 2>&1", - ], $server); + $lines = filter_var($lines, FILTER_VALIDATE_INT); + if ($lines === false || $lines <= 0) { + return $default; } + return min($lines, $max); +} + +function parseLogTimestampFlag(mixed $showTimestamps): bool +{ + return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; +} + +function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $command = "docker logs -n {$lines}"; + if ($server->isSwarm()) { + $command = "docker service logs -n {$lines}"; + } + + if ($showTimestamps) { + $command .= ' --timestamps'; + } + + return "{$command} ".escapeshellarg($container_id).' 2>&1'; +} + +function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); return $output; diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index ff77a78e2..f3c5359f7 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -4,6 +4,54 @@ use App\Models\Application; use App\Models\ServiceApplication; use Illuminate\Support\Collection; +function isValidDomainUrl(string $url): bool +{ + $components = parse_url($url); + + if ($components === false) { + return false; + } + + $scheme = $components['scheme'] ?? ''; + $host = $components['host'] ?? ''; + + if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') { + return false; + } + + $urlToValidate = $scheme.'://'; + + if (isset($components['user'])) { + $urlToValidate .= $components['user']; + + if (isset($components['pass'])) { + $urlToValidate .= ':'.$components['pass']; + } + + $urlToValidate .= '@'; + } + + $urlToValidate .= str_replace('_', '-', $host); + + if (isset($components['port'])) { + $urlToValidate .= ':'.$components['port']; + } + + if (isset($components['path'])) { + $urlToValidate .= $components['path']; + } + + if (isset($components['query'])) { + $urlToValidate .= '?'.$components['query']; + } + + if (isset($components['fragment'])) { + $urlToValidate .= '#'.$components['fragment']; + } + + return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false; +} + function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php new file mode 100644 index 000000000..a0b8ba67f --- /dev/null +++ b/bootstrap/helpers/email.php @@ -0,0 +1,20 @@ + for *.ghe.com, or /api/v3 for GHES) + */ +function githubApiUrlFromHtmlUrl(string $htmlUrl): string +{ + if (isGithubDotComHost($htmlUrl)) { + return 'https://api.github.com'; + } + + if (isGheDotComHost($htmlUrl)) { + return 'https://api.'.githubUrlHost($htmlUrl); + } + + return githubUrlOrigin($htmlUrl).'/api/v3'; +} + +/** + * Normalize a GitHub organization slug by trimming surrounding slashes and whitespace. + * + * @param string|null $organization The raw organization value + * @return string|null The trimmed organization, or null when blank + */ +function normalizeGithubOrganization(?string $organization): ?string +{ + if (blank($organization)) { + return null; + } + + return trim((string) $organization, "/ \t\n\r\0\x0B"); +} + +/** + * URL-encode a single GitHub path segment. + * + * @param string $segment The raw path segment + * @return string The raw-URL-encoded segment + */ +function encodeGithubPathSegment(string $segment): string +{ + return rawurlencode($segment); +} + function assertGithubClockInSync(string $apiUrl): void { $response = Http::get("{$apiUrl}/zen"); @@ -192,13 +327,17 @@ function syncGithubAppName(GithubApp $source, bool $throw = false): ?string function getInstallationPath(GithubApp $source): string { - $name = str(Str::kebab($source->name)); - $baseUrl = rtrim($source->html_url, '/'); - $host = parse_url($source->html_url, PHP_URL_HOST); - $host = blank($host) ? null : Str::lower($host); - $usesDataResidencyPath = filled($host) && Str::endsWith($host, '.ghe.com'); - $installation_path = $host === 'github.com' || $usesDataResidencyPath ? 'apps' : 'github-apps'; + $name = encodeGithubPathSegment(Str::kebab($source->name)); $state = Str::random(64); + $organization = normalizeGithubOrganization($source->organization); + + if (isGithubEnterpriseServerHost($source->html_url)) { + $path = "github-apps/{$name}"; + } elseif (isGheDotComHost($source->html_url) && filled($organization)) { + $path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}"; + } else { + $path = "apps/{$name}"; + } Cache::put('github-app-setup-state:'.hash('sha256', $state), [ 'action' => 'install', @@ -206,25 +345,19 @@ function getInstallationPath(GithubApp $source): string 'team_id' => $source->team_id, ], now()->addMinutes(60)); - if ($usesDataResidencyPath) { - $organization = str($source->organization)->trim('/'); - - if ($organization->isNotEmpty()) { - $organization = rawurlencode((string) $organization); - - return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]); - } - } - - return "$baseUrl/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); + return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) { - $github = GithubApp::where('uuid', $source->uuid)->first(); - $name = str(Str::kebab($github->name)); + $name = encodeGithubPathSegment(Str::kebab($source->name)); + $organization = normalizeGithubOrganization($source->organization); - return "$github->html_url/settings/apps/$name/permissions"; + if (filled($organization)) { + return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions"; + } + + return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions"; } function loadRepositoryByPage(GithubApp $source, string $token, int $page) @@ -281,6 +414,28 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re } } +function getGithubCommitMessage(?GithubApp $source, string $owner, string $repo, string $commitSha): ?string +{ + try { + if (! $source) { + return null; + } + + if (blank($owner) || blank($repo) || blank($commitSha) || $commitSha === 'HEAD') { + return null; + } + + $endpoint = "/repos/{$owner}/{$repo}/commits/{$commitSha}"; + $response = githubApi($source, $endpoint, 'get', null, false); + + $message = data_get($response, 'data.commit.message'); + + return is_string($message) ? $message : null; + } catch (Exception $e) { + return null; + } +} + function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): array { try { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 999919991..ff1d6563e 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -501,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } + + } + + // Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations. + if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) { + $parsed = parseServiceEnvironmentVariable($key->value()); + $normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value(); + $serviceExists = false; + foreach (array_keys($services) as $serviceNameKey) { + if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) { + $serviceExists = true; + break; + } + } + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); + $domainExists = data_get($domains->get($normalizedServiceName), 'domain'); + if (is_null($domainExists)) { + $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); + $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + $path = $value->value(); + if ($path !== '/') { + $domainValue = "$domainValue$path"; + } + } + if ($parsed['port'] && is_numeric($parsed['port'])) { + $domainValue = "$domainValue:{$parsed['port']}"; + } + $domains->put($normalizedServiceName, ['domain' => $domainValue]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } + } } } @@ -608,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($serviceExists) { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); $domainExists = data_get($domains->get($serviceName), 'domain'); // Update domain using URL with port if applicable diff --git a/config/constants.php b/config/constants.php index b9e3d600f..290ce3f95 100644 --- a/config/constants.php +++ b/config/constants.php @@ -16,7 +16,7 @@ return [ 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), - 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'), + 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), ], 'urls' => [ @@ -25,9 +25,7 @@ return [ ], 'services' => [ - // Temporary disabled until cache is implemented - // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json', - 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json', + 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json', 'file_name' => 'service-templates-latest.json', ], diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php index 4da7a2d08..689c26826 100644 --- a/database/factories/CloudProviderTokenFactory.php +++ b/database/factories/CloudProviderTokenFactory.php @@ -13,6 +13,11 @@ class CloudProviderTokenFactory extends Factory { protected $model = CloudProviderToken::class; + /** + * Define the model's default state. + * + * @return array + */ public function definition(): array { return [ diff --git a/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php new file mode 100644 index 000000000..2693011f1 --- /dev/null +++ b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php @@ -0,0 +1,44 @@ +string('vultr_instance_id')->nullable()->after('hetzner_server_status'); + }); + } + + if (! Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('vultr_instance_status')->nullable()->after('vultr_instance_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_status'); + }); + } + + if (Schema::hasColumn('servers', 'vultr_instance_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_id'); + }); + } + } +}; diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php new file mode 100644 index 000000000..a25fdc18b --- /dev/null +++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php @@ -0,0 +1,76 @@ +indexExists()) { + return; + } + + DB::table('tags') + ->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count')) + ->whereNotNull('team_id') + ->groupBy('team_id', 'name') + ->havingRaw('COUNT(*) > 1') + ->orderBy('keep_id') + ->cursor() + ->each(function ($duplicate): void { + DB::table('tags') + ->select('id') + ->where('team_id', $duplicate->team_id) + ->where('name', $duplicate->name) + ->where('id', '!=', $duplicate->keep_id) + ->orderBy('id') + ->cursor() + ->each(function ($duplicateTag) use ($duplicate): void { + DB::table('taggables') + ->where('tag_id', $duplicateTag->id) + ->orderBy('taggable_id') + ->cursor() + ->each(function ($taggable) use ($duplicate): void { + DB::table('taggables')->updateOrInsert([ + 'tag_id' => $duplicate->keep_id, + 'taggable_id' => $taggable->taggable_id, + 'taggable_type' => $taggable->taggable_type, + ]); + }); + + DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete(); + DB::table('tags')->where('id', $duplicateTag->id)->delete(); + }); + }); + + Schema::table('tags', function (Blueprint $table) { + $table->unique(['team_id', 'name'], 'tags_team_id_name_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! $this->indexExists()) { + return; + } + + Schema::table('tags', function (Blueprint $table) { + $table->dropUnique('tags_team_id_name_unique'); + }); + } + + private function indexExists(): bool + { + return collect(Schema::getIndexes('tags')) + ->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique'); + } +}; diff --git a/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php new file mode 100644 index 000000000..d2845c047 --- /dev/null +++ b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php @@ -0,0 +1,40 @@ +bigInteger('digitalocean_droplet_id')->nullable()->after('hetzner_server_status'); + } + + if (! Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->string('digitalocean_droplet_status')->nullable()->after('digitalocean_droplet_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + if (Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->dropColumn('digitalocean_droplet_status'); + } + + if (Schema::hasColumn('servers', 'digitalocean_droplet_id')) { + $table->dropColumn('digitalocean_droplet_id'); + } + }); + } +}; diff --git a/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php new file mode 100644 index 000000000..5d194cd7d --- /dev/null +++ b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; 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 new file mode 100644 index 000000000..500fa1fcf --- /dev/null +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + DB::table('cloud_init_scripts') + ->whereNull('uuid') + ->orderBy('id') + ->each(function (object $script): void { + DB::table('cloud_init_scripts') + ->where('id', $script->id) + ->update(['uuid' => new_public_id()]); + }); + } + + public function down(): void + { + Schema::table('cloud_init_scripts', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/openapi.json b/openapi.json index ca445ade0..7ffe9ecca 100644 --- a/openapi.json +++ b/openapi.json @@ -165,6 +165,10 @@ "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." + }, "static_image": { "type": "string", "enum": [ @@ -408,6 +412,13 @@ "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." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -615,6 +626,10 @@ "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." + }, "static_image": { "type": "string", "enum": [ @@ -858,6 +873,13 @@ "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." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1065,6 +1087,10 @@ "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." + }, "static_image": { "type": "string", "enum": [ @@ -1308,6 +1334,13 @@ "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." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1626,6 +1659,10 @@ "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, @@ -1662,6 +1699,13 @@ "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." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -1961,6 +2005,10 @@ "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, @@ -1997,6 +2045,13 @@ "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." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -2333,6 +2388,10 @@ "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." + }, "install_command": { "type": "string", "description": "The install command." @@ -2553,6 +2612,10 @@ "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" @@ -2675,6 +2738,16 @@ "format": "int32", "default": 100 } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -3291,6 +3364,91 @@ ] } }, + "\/applications\/{uuid}\/move": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Move", + "description": "Move application to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the application to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Application moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Application moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/storages": { "get": { "tags": [ @@ -3682,6 +3840,179 @@ ] } }, + "\/applications\/{uuid}\/tags": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Tags", + "description": "List tags for an application by UUID.", + "operationId": "list-tags-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Tag", + "description": "Add tag(s) to an application by UUID.", + "operationId": "create-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Tag", + "description": "Remove a tag from an application by UUID.", + "operationId": "delete-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -3709,7 +4040,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ] }, "team_id": { @@ -3767,7 +4099,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ], "example": "hetzner", "description": "The cloud provider." @@ -4980,6 +5313,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5112,6 +5452,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5240,6 +5587,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5372,6 +5726,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5504,6 +5865,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5648,6 +6016,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5792,6 +6167,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5924,6 +6306,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5952,6 +6341,80 @@ ] } }, + "\/databases\/{uuid}\/logs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get database logs.", + "description": "Get database logs by UUID.", + "operationId": "get-database-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get database logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { "delete": { "tags": [ @@ -6118,6 +6581,91 @@ ] } }, + "\/databases\/{uuid}\/move": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Move", + "description": "Move database to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the database to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/start": { "get": { "tags": [ @@ -6994,6 +7542,179 @@ ] } }, + "\/databases\/{uuid}\/tags": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Tags", + "description": "List tags for a database by UUID.", + "operationId": "list-tags-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a database by UUID.", + "operationId": "create-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a database by UUID.", + "operationId": "delete-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -7338,6 +8059,210 @@ ] } }, + "\/digitalocean\/regions": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean regions", + "operationId": "get-digitalocean-regions", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/sizes": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean sizes", + "operationId": "get-digitalocean-sizes", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean sizes." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/images": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean images", + "operationId": "get-digitalocean-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean images." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/ssh-keys": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean SSH keys", + "operationId": "get-digitalocean-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/digitalocean": { + "post": { + "tags": [ + "DigitalOcean" + ], + "summary": "Create a server on DigitalOcean", + "operationId": "create-digitalocean-server", + "responses": { + "201": { + "description": "DigitalOcean droplet created and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "DigitalOcean rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/github-apps": { "get": { "tags": [ @@ -7439,7 +8364,6 @@ "schema": { "required": [ "name", - "api_url", "html_url", "app_id", "installation_id", @@ -8236,6 +9160,139 @@ ] } }, + "\/hetzner\/firewalls": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Firewalls", + "description": "Get all existing Hetzner firewalls for the current project.", + "operationId": "get-hetzner-firewalls", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner firewalls.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/networks": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Networks", + "description": "Get all existing Hetzner private networks for the current project.", + "operationId": "get-hetzner-networks", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner networks.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "ip_range": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/servers\/hetzner": { "post": { "tags": [ @@ -8303,6 +9360,11 @@ "example": true, "description": "Enable IPv6 (default: true)" }, + "enable_backups": { + "type": "boolean", + "example": false, + "description": "Enable Hetzner server backups after creation (adds 20% to the monthly server fee)" + }, "hetzner_ssh_key_ids": { "type": "array", "items": { @@ -8310,6 +9372,20 @@ }, "description": "Additional Hetzner SSH key IDs" }, + "hetzner_firewall_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Existing Hetzner firewall IDs to apply during server creation" + }, + "hetzner_network_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Existing Hetzner network IDs to attach during server creation" + }, "cloud_init_script": { "type": "string", "description": "Cloud-init YAML script (optional)" @@ -10734,6 +11810,511 @@ ] } }, + "\/services\/{uuid}\/applications": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "List service applications", + "description": "List compose service applications (containers) for a single service.", + "operationId": "list-service-applications-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service applications for this service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application", + "description": "Get a single compose service application by service UUID and application UUID.", + "operationId": "get-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Service applications" + ], + "summary": "Update service application", + "description": "Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH \/services\/{uuid}).", + "operationId": "patch-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force_domain_override", + "in": "query", + "description": "When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "properties": { + "url": { + "description": "Comma-separated list of URLs (e.g. \"http:\/\/app.example.com:8080,https:\/\/app2.example.com\"). Stored as fqdn.", + "type": [ + "string", + "null" + ] + }, + "human_name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "exclude_from_status": { + "type": [ + "boolean", + "null" + ] + }, + "is_log_drain_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_gzip_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_stripprefix_enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts (unless force_domain_override)." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/logs": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application logs", + "description": "Get Docker logs for a single compose service container.", + "operationId": "get-service-application-logs-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "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": [ + "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": "start-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "When true, passes --build to docker compose up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "description": "When true, pulls the image for this compose service before up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "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": [ + "Service applications" + ], + "summary": "Restart service application container", + "description": "Restarts a single compose service container (docker restart).", + "operationId": "restart-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "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": [ + "Service applications" + ], + "summary": "Stop service application container", + "description": "Stops a single compose service container (docker stop).", + "operationId": "stop-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services": { "get": { "tags": [ @@ -10857,6 +12438,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the service." } }, "type": "object" @@ -11293,6 +12881,90 @@ ] } }, + "\/services\/{uuid}\/logs": { + "get": { + "tags": [ + "Services" + ], + "summary": "Get service logs.", + "description": "Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET \/services\/{uuid}`.", + "operationId": "get-service-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "sub_service_name", + "in": "query", + "description": "Sub-service name from `GET \/services\/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.", + "required": true, + "schema": { + "type": "string", + "example": "appwrite-console" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get service logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/envs": { "get": { "tags": [ @@ -11686,6 +13358,91 @@ ] } }, + "\/services\/{uuid}\/move": { + "post": { + "tags": [ + "Services" + ], + "summary": "Move", + "description": "Move service to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the service to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Service moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/start": { "get": { "tags": [ @@ -12195,6 +13952,215 @@ ] } }, + "\/services\/{uuid}\/tags": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Tags", + "description": "List tags for a service by UUID.", + "operationId": "list-tags-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a service by UUID.", + "operationId": "create-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a service by UUID.", + "operationId": "delete-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List", + "description": "List all tags for the current team.", + "operationId": "list-tags", + "responses": { + "200": { + "description": "All tags for the current team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -12396,6 +14362,139 @@ } ] } + }, + "\/vultr\/regions": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Regions", + "description": "Get all available Vultr regions.", + "operationId": "get-vultr-regions", + "responses": { + "200": { + "description": "List of Vultr regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/plans": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Plans", + "description": "Get all available Vultr plans.", + "operationId": "get-vultr-plans", + "responses": { + "200": { + "description": "List of Vultr plans." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/os": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Operating Systems", + "description": "Get all available Vultr operating systems.", + "operationId": "get-vultr-operating-systems", + "responses": { + "200": { + "description": "List of Vultr operating systems." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/ssh-keys": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr SSH Keys", + "description": "Get all Vultr SSH keys available to the selected token.", + "operationId": "get-vultr-ssh-keys", + "responses": { + "200": { + "description": "List of Vultr SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/vultr": { + "post": { + "tags": [ + "Vultr" + ], + "summary": "Create Vultr Server", + "description": "Create a Vultr instance and link it as a Coolify server.", + "operationId": "create-vultr-server", + "responses": { + "201": { + "description": "Vultr server created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Vultr API rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -13415,6 +15514,24 @@ }, "type": "object" }, + "Tag": { + "description": "Tag model", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, "Team": { "description": "Team model", "properties": { @@ -13637,6 +15754,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "DigitalOcean", + "description": "DigitalOcean" + }, { "name": "GitHub Apps", "description": "GitHub Apps" @@ -13665,13 +15786,25 @@ "name": "Servers", "description": "Servers" }, + { + "name": "Service applications", + "description": "Service applications" + }, { "name": "Services", "description": "Services" }, + { + "name": "Tags", + "description": "Tags" + }, { "name": "Teams", "description": "Teams" + }, + { + "name": "Vultr", + "description": "Vultr" } ] } diff --git a/openapi.yaml b/openapi.yaml index 6182cacd3..3b2f5c4d5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -118,6 +118,9 @@ paths: 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.' static_image: type: string enum: ['nginx:alpine'] @@ -290,6 +293,10 @@ paths: 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.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -405,6 +412,9 @@ paths: 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.' static_image: type: string enum: ['nginx:alpine'] @@ -577,6 +587,10 @@ paths: 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.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -692,6 +706,9 @@ paths: 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.' static_image: type: string enum: ['nginx:alpine'] @@ -864,6 +881,10 @@ paths: 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.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -1063,6 +1084,9 @@ paths: 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 @@ -1092,6 +1116,10 @@ paths: 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.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1277,6 +1305,9 @@ paths: 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 @@ -1306,6 +1337,10 @@ paths: 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.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1507,6 +1542,9 @@ paths: 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.' install_command: type: string description: 'The install command.' @@ -1663,6 +1701,9 @@ 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': @@ -1716,6 +1757,14 @@ paths: type: integer format: int32 default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false responses: '200': description: 'Get application logs by UUID.' @@ -2092,6 +2141,57 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/move': + post: + tags: + - Applications + summary: Move + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-application-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the application to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Application moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Application moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/applications/{uuid}/storages': get: tags: @@ -2347,6 +2447,121 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/tags': + get: + tags: + - Applications + summary: 'List Tags' + description: 'List tags for an application by UUID.' + operationId: list-tags-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Tag' + description: 'Add tag(s) to an application by UUID.' + operationId: create-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Applications + summary: 'Delete Tag' + description: 'Remove a tag from an application by UUID.' + operationId: delete-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -2362,7 +2577,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -2390,7 +2605,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean] + enum: [hetzner, digitalocean, vultr] example: hetzner description: 'The cloud provider.' token: @@ -3215,6 +3430,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3310,6 +3529,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3402,6 +3625,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3497,6 +3724,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3592,6 +3823,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3696,6 +3931,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3800,6 +4039,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3895,6 +4138,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3908,6 +4155,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/logs': + get: + tags: + - Databases + summary: 'Get database logs.' + description: 'Get database logs by UUID.' + operationId: get-database-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get database logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': delete: tags: @@ -4001,6 +4299,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/move': + post: + tags: + - Databases + summary: Move + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-database-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the database to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Database moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Database moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/databases/{uuid}/start': get: tags: @@ -4556,6 +4905,121 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/tags': + get: + tags: + - Databases + summary: 'List Tags' + description: 'List tags for a database by UUID.' + operationId: list-tags-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Tag' + description: 'Add tag(s) to a database by UUID.' + operationId: create-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Databases + summary: 'Delete Tag' + description: 'Remove a tag from a database by UUID.' + operationId: delete-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -4768,6 +5232,144 @@ paths: security: - bearerAuth: [] + /digitalocean/regions: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean regions' + operationId: get-digitalocean-regions + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean regions.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/sizes: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean sizes' + operationId: get-digitalocean-sizes + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean sizes.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/images: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean images' + operationId: get-digitalocean-images + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean images.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/ssh-keys: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean SSH keys' + operationId: get-digitalocean-ssh-keys + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean SSH keys.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /servers/digitalocean: + post: + tags: + - DigitalOcean + summary: 'Create a server on DigitalOcean' + operationId: create-digitalocean-server + responses: + '201': + description: 'DigitalOcean droplet created and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'DigitalOcean rate limit exceeded.' + security: + - + bearerAuth: [] /github-apps: get: tags: @@ -4806,7 +5408,6 @@ paths: schema: required: - name - - api_url - html_url - app_id - installation_id @@ -5245,6 +5846,86 @@ paths: security: - bearerAuth: [] + /hetzner/firewalls: + get: + tags: + - Hetzner + summary: 'Get Hetzner Firewalls' + description: 'Get all existing Hetzner firewalls for the current project.' + operationId: get-hetzner-firewalls + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner firewalls.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/networks: + get: + tags: + - Hetzner + summary: 'Get Hetzner Networks' + description: 'Get all existing Hetzner private networks for the current project.' + operationId: get-hetzner-networks + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner networks.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, ip_range: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /servers/hetzner: post: tags: @@ -5301,10 +5982,22 @@ paths: type: boolean example: true description: 'Enable IPv6 (default: true)' + enable_backups: + type: boolean + example: false + description: 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)' hetzner_ssh_key_ids: type: array items: { type: integer } description: 'Additional Hetzner SSH key IDs' + hetzner_firewall_ids: + type: array + items: { type: integer } + description: 'Existing Hetzner firewall IDs to apply during server creation' + hetzner_network_ids: + type: array + items: { type: integer } + description: 'Existing Hetzner network IDs to attach during server creation' cloud_init_script: type: string description: 'Cloud-init YAML script (optional)' @@ -6844,6 +7537,330 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/applications': + get: + tags: + - 'Service applications' + summary: 'List service applications' + description: 'List compose service applications (containers) for a single service.' + operationId: list-service-applications-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service applications for this service.' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}': + get: + tags: + - 'Service applications' + summary: 'Get service application' + description: 'Get a single compose service application by service UUID and application UUID.' + operationId: get-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Service applications' + summary: 'Update service application' + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).' + operationId: patch-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: force_domain_override + in: query + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).' + required: false + schema: + type: boolean + default: false + requestBody: + content: + application/json: + schema: + properties: + url: + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + type: [string, 'null'] + human_name: + type: [string, 'null'] + description: + type: [string, 'null'] + image: + type: [string, 'null'] + exclude_from_status: + type: [boolean, 'null'] + is_log_drain_enabled: + type: [boolean, 'null'] + is_gzip_enabled: + type: [boolean, 'null'] + is_stripprefix_enabled: + type: [boolean, 'null'] + type: object + responses: + '200': + description: 'Updated service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts (unless force_domain_override).' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/logs': + get: + tags: + - 'Service applications' + summary: 'Get service application logs' + description: 'Get Docker logs for a single compose service container.' + operationId: get-service-application-logs-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + 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: + - '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: start-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: force + in: query + description: 'When true, passes --build to docker compose up.' + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + description: 'When true, pulls the image for this compose service before up.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '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: + - 'Service applications' + summary: 'Restart service application container' + description: 'Restarts a single compose service container (docker restart).' + operationId: restart-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '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: + - 'Service applications' + summary: 'Stop service application container' + description: 'Stops a single compose service container (docker stop).' + operationId: stop-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] /services: get: tags: @@ -6929,6 +7946,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the service.' type: object responses: '201': @@ -7150,6 +8171,65 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/logs': + get: + tags: + - Services + summary: 'Get service logs.' + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.' + operationId: get-service-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + format: uuid + - + name: sub_service_name + in: query + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.' + required: true + schema: + type: string + example: appwrite-console + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get service logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/services/{uuid}/envs': get: tags: @@ -7392,6 +8472,57 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/move': + post: + tags: + - Services + summary: Move + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-service-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the service to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Service moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Service moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/services/{uuid}/start': get: tags: @@ -7722,6 +8853,144 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/tags': + get: + tags: + - Services + summary: 'List Tags' + description: 'List tags for a service by UUID.' + operationId: list-tags-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Tag' + description: 'Add tag(s) to a service by UUID.' + operationId: create-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Services + summary: 'Delete Tag' + description: 'Remove a tag from a service by UUID.' + operationId: delete-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /tags: + get: + tags: + - Tags + summary: List + description: 'List all tags for the current team.' + operationId: list-tags + responses: + '200': + description: 'All tags for the current team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /teams: get: tags: @@ -7853,6 +9122,93 @@ paths: security: - bearerAuth: [] + /vultr/regions: + get: + tags: + - Vultr + summary: 'Get Vultr Regions' + description: 'Get all available Vultr regions.' + operationId: get-vultr-regions + responses: + '200': + description: 'List of Vultr regions.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/plans: + get: + tags: + - Vultr + summary: 'Get Vultr Plans' + description: 'Get all available Vultr plans.' + operationId: get-vultr-plans + responses: + '200': + description: 'List of Vultr plans.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/os: + get: + tags: + - Vultr + summary: 'Get Vultr Operating Systems' + description: 'Get all available Vultr operating systems.' + operationId: get-vultr-operating-systems + responses: + '200': + description: 'List of Vultr operating systems.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/ssh-keys: + get: + tags: + - Vultr + summary: 'Get Vultr SSH Keys' + description: 'Get all Vultr SSH keys available to the selected token.' + operationId: get-vultr-ssh-keys + responses: + '200': + description: 'List of Vultr SSH keys.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/vultr: + post: + tags: + - Vultr + summary: 'Create Vultr Server' + description: 'Create a Vultr instance and link it as a Coolify server.' + operationId: create-vultr-server + responses: + '201': + description: 'Vultr server created.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Vultr API rate limit exceeded.' + security: + - + bearerAuth: [] components: schemas: Application: @@ -8602,6 +9958,18 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Tag: + description: 'Tag model' + properties: + uuid: + type: string + name: + type: string + created_at: + type: string + updated_at: + type: string + type: object Team: description: 'Team model' properties: @@ -8749,6 +10117,9 @@ tags: - name: Deployments description: Deployments + - + name: DigitalOcean + description: DigitalOcean - name: 'GitHub Apps' description: 'GitHub Apps' @@ -8770,9 +10141,18 @@ tags: - name: Servers description: Servers + - + name: 'Service applications' + description: 'Service applications' - name: Services description: Services + - + name: Tags + description: Tags - name: Teams description: Teams + - + name: Vultr + description: Vultr diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 751db0754..9c9a405aa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.2.0" + "version": "4.1.2" }, "nightly": { - "version": "4.2.1" + "version": "4.2.0" }, "helper": { "version": "1.0.14" diff --git a/resources/css/app.css b/resources/css/app.css index a4f42149d..cb5d580a8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -97,6 +97,90 @@ } } +@keyframes coolbox-border-track { + 0% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 33% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 34% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 50% { + background-position: 100% 0, 100% 100%, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 51% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 84% { + background-position: 0 0, 100% 0, 0 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 85% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } + + 100% { + background-position: 0 0, 100% 0, 100% 100%, 0 0; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } +} + +@layer components { + .coolbox-loading { + @apply overflow-hidden border-transparent; + isolation: isolate; + } + + .coolbox-loading::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)); + background-repeat: no-repeat; + animation: coolbox-border-track 2400ms linear infinite; + pointer-events: none; + z-index: 0; + } + + .coolbox-loading::after { + content: ""; + position: absolute; + inset: 2px; + border-radius: calc(0.25rem - 2px); + background: white; + pointer-events: none; + z-index: 1; + } + + .dark .coolbox-loading::after { + background: var(--color-coolgray-100); + } + + .coolbox-loading > * { + position: relative; + z-index: 2; + } +} + /* * Base styles */ diff --git a/resources/css/utilities.css b/resources/css/utilities.css index c982f9f86..1df8ac836 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -289,7 +289,7 @@ } @utility info-helper-popup { - @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words; + @apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words; } @utility buyme { diff --git a/resources/views/components/digital-ocean-icon.blade.php b/resources/views/components/digital-ocean-icon.blade.php new file mode 100644 index 000000000..5652730d6 --- /dev/null +++ b/resources/views/components/digital-ocean-icon.blade.php @@ -0,0 +1,12 @@ +@php + $iconAttributes = $attributes->has('class') + ? $attributes->class('shrink-0') + : $attributes->merge(['class' => 'size-6 shrink-0']); +@endphp + + + DigitalOcean + + diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index 2bb917f79..b48b04143 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -1,3 +1,9 @@ +@props([ + 'inline' => false, + 'triggerClass' => '', + 'panelClass' => '', +]) +
- -
-
+ :style="panelStyles" @class([ + 'mt-1 w-full' => $inline, + 'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline, + ]) x-cloak> +
! $inline, + 'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline, + $panelClass, + ])> {{ $slot }}
diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index f1efd8c6c..89b177c1f 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -1,4 +1,4 @@ -@if ($authDisabled) +@if ($authDisabled || filled($tooltip)) - You do not have permission to perform this action. + {{ $tooltip ?: 'You do not have permission to perform this action.' }}
@endif diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 2542839f1..900beddd0 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,6 +1,47 @@ -
merge(['class' => 'group']) }}> -
+
merge(['class' => 'inline-block align-middle']) }}> +
@isset($icon) {{ $icon }} @else @@ -9,11 +50,13 @@ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"> @endisset -
-
-
- {!! $helper !!} +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 5efc9102b..830b7e09c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'disabledTooltip' => null, 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', @@ -156,11 +157,11 @@ @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index dc1191b44..96bb3467c 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,6 +8,7 @@ 'content' => null, 'closeOutside' => true, 'isFullWidth' => false, + 'wireIgnore' => true, ]) @php @@ -17,7 +18,7 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif> @if ($content)
{{ $content }} diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 975a1bf4b..898f684c0 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -52,7 +52,7 @@