mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 00:15:01 +00:00
Merge branch 'v4.x' into allow-dep
This commit is contained in:
296 files changed
+18332
-6636
No files matched your search
@@ -229,6 +229,8 @@ class StartPostgresql
|
||||
}
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
ray($this->commands);
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ class GetContainersStatus
|
||||
|
||||
public $server;
|
||||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
@@ -94,7 +96,11 @@ class GetContainersStatus
|
||||
}
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
if ($containerStatus === 'restarting') {
|
||||
$containerStatus = "restarting ($containerHealth)";
|
||||
} else {
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
if ($applicationId) {
|
||||
@@ -119,11 +125,16 @@ class GetContainersStatus
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$foundApplications[] = $application->id;
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->applicationContainerStatuses)) {
|
||||
$this->applicationContainerStatuses = collect();
|
||||
}
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
@@ -320,6 +331,97 @@ class GetContainersStatus
|
||||
}
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
}
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
|
||||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$user = User::create([
|
||||
'id' => 0,
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
@@ -52,7 +52,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
} else {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $reset = false)
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
if ($proxyType === 'NONE') {
|
||||
return 'OK';
|
||||
}
|
||||
$proxy_path = $server->proxyPath();
|
||||
$payload = [
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml",
|
||||
];
|
||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
||||
if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) {
|
||||
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
|
||||
}
|
||||
if (! $proxy_configuration || is_null($proxy_configuration)) {
|
||||
throw new \Exception('Could not generate proxy configuration');
|
||||
}
|
||||
|
||||
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
|
||||
|
||||
return $proxy_configuration;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class CheckProxy
|
||||
|
||||
try {
|
||||
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
||||
$proxyCompose = CheckConfiguration::run($server);
|
||||
$proxyCompose = GetProxyConfiguration::run($server);
|
||||
if (isset($proxyCompose)) {
|
||||
$yaml = Yaml::parse($proxyCompose);
|
||||
$configPorts = [];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetProxyConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $forceRegenerate = false): string
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
if ($proxyType === 'NONE') {
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
$proxy_path = $server->proxyPath();
|
||||
$proxy_configuration = null;
|
||||
|
||||
// If not forcing regeneration, try to read existing configuration
|
||||
if (! $forceRegenerate) {
|
||||
$payload = [
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||
];
|
||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
||||
}
|
||||
|
||||
// Generate default configuration if:
|
||||
// 1. Force regenerate is requested
|
||||
// 2. Configuration file doesn't exist or is empty
|
||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
|
||||
}
|
||||
|
||||
if (empty($proxy_configuration)) {
|
||||
throw new \Exception('Could not get or generate proxy configuration');
|
||||
}
|
||||
|
||||
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
|
||||
|
||||
return $proxy_configuration;
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -5,22 +5,21 @@ namespace App\Actions\Proxy;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class SaveConfiguration
|
||||
class SaveProxyConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, ?string $proxy_settings = null)
|
||||
public function handle(Server $server, string $configuration): void
|
||||
{
|
||||
if (is_null($proxy_settings)) {
|
||||
$proxy_settings = CheckConfiguration::run($server, true);
|
||||
}
|
||||
$proxy_path = $server->proxyPath();
|
||||
$docker_compose_yml_base64 = base64_encode($proxy_settings);
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
|
||||
// Update the saved settings hash
|
||||
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||
$server->save();
|
||||
|
||||
return instant_remote_process([
|
||||
// Transfer the configuration file to the server
|
||||
instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
||||
], $server);
|
||||
@@ -21,11 +21,11 @@ class StartProxy
|
||||
}
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
$configuration = CheckConfiguration::run($server);
|
||||
$configuration = GetProxyConfiguration::run($server);
|
||||
if (! $configuration) {
|
||||
throw new \Exception('Configuration is not synced');
|
||||
}
|
||||
SaveConfiguration::run($server, $configuration);
|
||||
SaveProxyConfiguration::run($server, $configuration);
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
|
||||
$server->save();
|
||||
|
||||
@@ -102,7 +102,6 @@ class CheckUpdates
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ray('Error:', $e->getMessage());
|
||||
|
||||
return [
|
||||
'osId' => $osId,
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use Illuminate\Support\Arr;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ServerCheck
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public bool $isSentinel = false;
|
||||
|
||||
public $containers;
|
||||
|
||||
public $databases;
|
||||
|
||||
public function handle(Server $server, $data = null)
|
||||
{
|
||||
$this->server = $server;
|
||||
try {
|
||||
if ($this->server->isFunctional() === false) {
|
||||
return 'Server is not functional.';
|
||||
}
|
||||
|
||||
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
|
||||
|
||||
if (isset($data)) {
|
||||
$data = collect($data);
|
||||
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
$containerReplicates = null;
|
||||
$this->isSentinel = true;
|
||||
} else {
|
||||
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
|
||||
// ServerStorageCheckJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if (is_null($this->containers)) {
|
||||
return 'No containers found.';
|
||||
}
|
||||
|
||||
if (isset($containerReplicates)) {
|
||||
foreach ($containerReplicates as $containerReplica) {
|
||||
$name = data_get($containerReplica, 'Name');
|
||||
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
|
||||
if (data_get($container, 'Spec.Name') === $name) {
|
||||
$replicas = data_get($containerReplica, 'Replicas');
|
||||
$running = str($replicas)->explode('/')[0];
|
||||
$total = str($replicas)->explode('/')[1];
|
||||
if ($running === $total) {
|
||||
data_set($container, 'State.Status', 'running');
|
||||
data_set($container, 'State.Health.Status', 'healthy');
|
||||
} else {
|
||||
data_set($container, 'State.Status', 'starting');
|
||||
data_set($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
}
|
||||
|
||||
return $container;
|
||||
});
|
||||
}
|
||||
}
|
||||
$this->checkContainers();
|
||||
|
||||
if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
|
||||
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
|
||||
} else {
|
||||
return data_get($value, 'Name') === '/coolify-proxy';
|
||||
}
|
||||
})->first();
|
||||
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
|
||||
if (! $foundProxyContainer || $proxyStatus !== 'running') {
|
||||
try {
|
||||
$shouldStart = CheckProxy::run($this->server);
|
||||
if ($shouldStart) {
|
||||
StartProxy::run($this->server, async: false);
|
||||
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
|
||||
$this->server->save();
|
||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkLogDrainContainer()
|
||||
{
|
||||
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
|
||||
return data_get($value, 'Name') === '/coolify-log-drain';
|
||||
})->first();
|
||||
if ($foundLogDrainContainer) {
|
||||
$status = data_get($foundLogDrainContainer, 'State.Status');
|
||||
if ($status !== 'running') {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
} else {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkContainers()
|
||||
{
|
||||
foreach ($this->containers as $container) {
|
||||
if ($this->isSentinel) {
|
||||
$labels = Arr::undot(data_get($container, 'labels'));
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
$labels = Arr::undot(data_get($container, 'Spec.Labels'));
|
||||
} else {
|
||||
$labels = Arr::undot(data_get($container, 'Config.Labels'));
|
||||
}
|
||||
}
|
||||
$managed = data_get($labels, 'coolify.managed');
|
||||
if (! $managed) {
|
||||
continue;
|
||||
}
|
||||
$uuid = data_get($labels, 'coolify.name');
|
||||
if (! $uuid) {
|
||||
$uuid = data_get($labels, 'com.docker.compose.service');
|
||||
}
|
||||
|
||||
if ($this->isSentinel) {
|
||||
$containerStatus = data_get($container, 'state');
|
||||
$containerHealth = data_get($container, 'health_status');
|
||||
} else {
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
$serviceId = data_get($labels, 'coolify.serviceId');
|
||||
$databaseId = data_get($labels, 'coolify.databaseId');
|
||||
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
|
||||
|
||||
if ($applicationId) {
|
||||
// Application
|
||||
if ($pullRequestId != 0) {
|
||||
if (str($applicationId)->contains('-')) {
|
||||
$applicationId = str($applicationId)->before('-');
|
||||
}
|
||||
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
|
||||
if ($preview) {
|
||||
$preview->update(['status' => $containerStatus]);
|
||||
}
|
||||
} else {
|
||||
$application = Application::where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$application->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif (isset($serviceId)) {
|
||||
// Service
|
||||
$subType = data_get($labels, 'coolify.service.subType');
|
||||
$subId = data_get($labels, 'coolify.service.subId');
|
||||
$service = Service::where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$service = ServiceApplication::where('id', $subId)->first();
|
||||
} else {
|
||||
$service = ServiceDatabase::where('id', $subId)->first();
|
||||
}
|
||||
if ($service) {
|
||||
$service->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
if ($subType === 'database') {
|
||||
$isPublic = data_get($service, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Database
|
||||
if (is_null($this->databases)) {
|
||||
$this->databases = $this->server->databases();
|
||||
}
|
||||
$database = $this->databases->where('uuid', $uuid)->first();
|
||||
if ($database) {
|
||||
$database->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
|
||||
$isPublic = data_get($database, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($database);
|
||||
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ class StartSentinel
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
|
||||
{
|
||||
if ($server->isSwarm() || $server->isBuildServer()) {
|
||||
return;
|
||||
@@ -44,7 +44,9 @@ class StartSentinel
|
||||
];
|
||||
if (isDev()) {
|
||||
// data_set($environments, 'DEBUG', 'true');
|
||||
// $image = 'sentinel';
|
||||
if ($customImage && ! empty($customImage)) {
|
||||
$image = $customImage;
|
||||
}
|
||||
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
|
||||
}
|
||||
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
|
||||
|
||||
@@ -26,22 +26,22 @@ class ComplexStatusCheck
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$container = format_docker_command_output_to_json($container);
|
||||
if ($container->count() === 1) {
|
||||
$container = $container->first();
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
$statusToSet = $this->aggregateContainerStatuses($application, $containers);
|
||||
|
||||
if ($is_main_server) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$application->update(['status' => $statusToSet]);
|
||||
}
|
||||
} else {
|
||||
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
|
||||
$statusFromDb = $additional_server->first()->pivot->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -57,4 +57,78 @@ class ComplexStatusCheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function aggregateContainerStatuses($application, $containers)
|
||||
{
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
$relevantContainerCount = 0;
|
||||
|
||||
foreach ($containers as $container) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
if ($serviceName && $excludedContainers->contains($serviceName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relevantContainerCount++;
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
|
||||
if ($containerStatus === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($containerStatus === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($containerHealth === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($relevantContainerCount === 0) {
|
||||
return 'running:healthy';
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscription
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
private ?StripeClient $stripe = null;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
|
||||
if (! $isDryRun && isCloud()) {
|
||||
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
}
|
||||
|
||||
public function getSubscriptionsPreview(): Collection
|
||||
{
|
||||
$subscriptions = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include subscriptions from teams where user is owner
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' && $team->subscription) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
// Only include active subscriptions
|
||||
if ($subscription->stripe_subscription_id &&
|
||||
$subscription->stripe_invoice_paid) {
|
||||
$subscriptions->push($subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subscriptions;
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'cancelled' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$cancelledCount = 0;
|
||||
$failedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
$subscriptions = $this->getSubscriptionsPreview();
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$this->cancelSingleSubscription($subscription);
|
||||
$cancelledCount++;
|
||||
} catch (\Exception $e) {
|
||||
$failedCount++;
|
||||
$errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
|
||||
$errors[] = $errorMessage;
|
||||
\Log::error($errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'cancelled' => $cancelledCount,
|
||||
'failed' => $failedCount,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function cancelSingleSubscription(Subscription $subscription): void
|
||||
{
|
||||
if (! $this->stripe) {
|
||||
throw new \Exception('Stripe client not initialized');
|
||||
}
|
||||
|
||||
$subscriptionId = $subscription->stripe_subscription_id;
|
||||
|
||||
// Cancel the subscription immediately (not at period end)
|
||||
$this->stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local database
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'User account deleted',
|
||||
'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
// Call the team's subscription ended method to handle cleanup
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
|
||||
\Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a single subscription by ID (helper method for external use)
|
||||
*/
|
||||
public static function cancelById(string $subscriptionId): bool
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local record if exists
|
||||
$subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserResources
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getResourcesPreview(): array
|
||||
{
|
||||
$applications = collect();
|
||||
$databases = collect();
|
||||
$services = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Get all servers for this team
|
||||
$servers = $team->servers;
|
||||
|
||||
foreach ($servers as $server) {
|
||||
// Get applications
|
||||
$serverApplications = $server->applications;
|
||||
$applications = $applications->merge($serverApplications);
|
||||
|
||||
// Get databases
|
||||
$serverDatabases = $this->getAllDatabasesForServer($server);
|
||||
$databases = $databases->merge($serverDatabases);
|
||||
|
||||
// Get services
|
||||
$serverServices = $server->services;
|
||||
$services = $services->merge($serverServices);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'applications' => $applications->unique('id'),
|
||||
'databases' => $databases->unique('id'),
|
||||
'services' => $services->unique('id'),
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCounts = [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
|
||||
$resources = $this->getResourcesPreview();
|
||||
|
||||
// Delete applications
|
||||
foreach ($resources['applications'] as $application) {
|
||||
try {
|
||||
$application->forceDelete();
|
||||
$deletedCounts['applications']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete databases
|
||||
foreach ($resources['databases'] as $database) {
|
||||
try {
|
||||
$database->forceDelete();
|
||||
$deletedCounts['databases']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete services
|
||||
foreach ($resources['services'] as $service) {
|
||||
try {
|
||||
$service->forceDelete();
|
||||
$deletedCounts['services']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $deletedCounts;
|
||||
}
|
||||
|
||||
private function getAllDatabasesForServer($server): Collection
|
||||
{
|
||||
$databases = collect();
|
||||
|
||||
// Get all standalone database types
|
||||
$databases = $databases->merge($server->postgresqls);
|
||||
$databases = $databases->merge($server->mysqls);
|
||||
$databases = $databases->merge($server->mariadbs);
|
||||
$databases = $databases->merge($server->mongodbs);
|
||||
$databases = $databases->merge($server->redis);
|
||||
$databases = $databases->merge($server->keydbs);
|
||||
$databases = $databases->merge($server->dragonflies);
|
||||
$databases = $databases->merge($server->clickhouses);
|
||||
|
||||
return $databases;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserServers
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getServersPreview(): Collection
|
||||
{
|
||||
$servers = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include servers from teams where user is owner or admin
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' || $userRole === 'admin') {
|
||||
$teamServers = $team->servers;
|
||||
$servers = $servers->merge($teamServers);
|
||||
}
|
||||
}
|
||||
|
||||
// Return unique servers (in case same server is in multiple teams)
|
||||
return $servers->unique('id');
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'servers' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
$servers = $this->getServersPreview();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
// Skip the default server (ID 0) which is the Coolify host
|
||||
if ($server->id === 0) {
|
||||
\Log::info('Skipping deletion of Coolify host server (ID: 0)');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// The Server model's forceDeleting event will handle cleanup of:
|
||||
// - destinations
|
||||
// - settings
|
||||
$server->forceDelete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'servers' => $deletedCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
class DeleteUserTeams
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getTeamsPreview(): array
|
||||
{
|
||||
$teamsToDelete = collect();
|
||||
$teamsToTransfer = collect();
|
||||
$teamsToLeave = collect();
|
||||
$edgeCases = collect();
|
||||
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Skip root team (ID 0)
|
||||
if ($team->id === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userRole = $team->pivot->role;
|
||||
$memberCount = $team->members->count();
|
||||
|
||||
if ($memberCount === 1) {
|
||||
// User is alone in the team - delete it
|
||||
$teamsToDelete->push($team);
|
||||
} elseif ($userRole === 'owner') {
|
||||
// Check if there are other owners
|
||||
$otherOwners = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'owner';
|
||||
});
|
||||
|
||||
if ($otherOwners->isNotEmpty()) {
|
||||
// There are other owners, but check if this user is paying for the subscription
|
||||
if ($this->isUserPayingForTeamSubscription($team)) {
|
||||
// User is paying for the subscription - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
|
||||
]);
|
||||
} else {
|
||||
// There are other owners and user is not paying, just remove this user
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
} else {
|
||||
// User is the only owner, check for replacement
|
||||
$newOwner = $this->findNewOwner($team);
|
||||
if ($newOwner) {
|
||||
$teamsToTransfer->push([
|
||||
'team' => $team,
|
||||
'new_owner' => $newOwner,
|
||||
]);
|
||||
} else {
|
||||
// No suitable replacement found - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is just a member - remove them from the team
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'to_delete' => $teamsToDelete,
|
||||
'to_transfer' => $teamsToTransfer,
|
||||
'to_leave' => $teamsToLeave,
|
||||
'edge_cases' => $edgeCases,
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$counts = [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
|
||||
$preview = $this->getTeamsPreview();
|
||||
|
||||
// Check for edge cases - should not happen here as we check earlier, but be safe
|
||||
if ($preview['edge_cases']->isNotEmpty()) {
|
||||
throw new \Exception('Edge cases detected during execution. This should not happen.');
|
||||
}
|
||||
|
||||
// Delete teams where user is alone
|
||||
foreach ($preview['to_delete'] as $team) {
|
||||
try {
|
||||
// The Team model's deleting event will handle cleanup of:
|
||||
// - private keys
|
||||
// - sources
|
||||
// - tags
|
||||
// - environment variables
|
||||
// - s3 storages
|
||||
// - notification settings
|
||||
$team->delete();
|
||||
$counts['deleted']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer ownership for teams where user is owner but not alone
|
||||
foreach ($preview['to_transfer'] as $item) {
|
||||
try {
|
||||
$team = $item['team'];
|
||||
$newOwner = $item['new_owner'];
|
||||
|
||||
// Update the new owner's role to owner
|
||||
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
||||
|
||||
// Remove the current user from the team
|
||||
$team->members()->detach($this->user->id);
|
||||
|
||||
$counts['transferred']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from teams where they're just a member
|
||||
foreach ($preview['to_leave'] as $team) {
|
||||
try {
|
||||
$team->members()->detach($this->user->id);
|
||||
$counts['left']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function findNewOwner(Team $team): ?User
|
||||
{
|
||||
// Only look for admins as potential new owners
|
||||
// We don't promote regular members automatically
|
||||
$otherAdmin = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'admin';
|
||||
})
|
||||
->first();
|
||||
|
||||
return $otherAdmin;
|
||||
}
|
||||
|
||||
private function isUserPayingForTeamSubscription(Team $team): bool
|
||||
{
|
||||
if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In Stripe, we need to check if the customer email matches the user's email
|
||||
// This would require a Stripe API call to get customer details
|
||||
// For now, we'll check if the subscription was created by this user
|
||||
|
||||
// Alternative approach: Check if user is the one who initiated the subscription
|
||||
// We could store this information when the subscription is created
|
||||
// For safety, we'll assume if there's an active subscription and multiple owners,
|
||||
// we should treat it as an edge case that needs manual review
|
||||
|
||||
if ($team->subscription->stripe_subscription_id &&
|
||||
$team->subscription->stripe_invoice_paid) {
|
||||
// Active subscription exists - we should be cautious
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,5 @@ class CleanupDatabase extends Command
|
||||
if ($this->option('yes')) {
|
||||
$scheduled_task_executions->delete();
|
||||
}
|
||||
|
||||
// Cleanup webhooks table
|
||||
$webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days));
|
||||
$count = $webhooks->count();
|
||||
echo "Delete $count entries from webhooks.\n";
|
||||
if ($this->option('yes')) {
|
||||
$webhooks->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CleanupHelperContainersJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -72,7 +73,7 @@ class CleanupStuckedResources extends Command
|
||||
$applications = Application::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($applications as $application) {
|
||||
echo "Deleting stuck application: {$application->name}\n";
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
@@ -82,26 +83,35 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($applicationsPreviews as $applicationPreview) {
|
||||
if (! data_get($applicationPreview, 'application')) {
|
||||
echo "Deleting stuck application preview: {$applicationPreview->uuid}\n";
|
||||
$applicationPreview->delete();
|
||||
DeleteResourceJob::dispatch($applicationPreview);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($applicationsPreviews as $applicationPreview) {
|
||||
echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n";
|
||||
DeleteResourceJob::dispatch($applicationPreview);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($postgresqls as $postgresql) {
|
||||
echo "Deleting stuck postgresql: {$postgresql->name}\n";
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($redis as $redis) {
|
||||
$rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($rediss as $redis) {
|
||||
echo "Deleting stuck redis: {$redis->name}\n";
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck redis: {$e->getMessage()}\n";
|
||||
@@ -110,7 +120,7 @@ class CleanupStuckedResources extends Command
|
||||
$keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($keydbs as $keydb) {
|
||||
echo "Deleting stuck keydb: {$keydb->name}\n";
|
||||
$keydb->forceDelete();
|
||||
DeleteResourceJob::dispatch($keydb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck keydb: {$e->getMessage()}\n";
|
||||
@@ -119,7 +129,7 @@ class CleanupStuckedResources extends Command
|
||||
$dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($dragonflies as $dragonfly) {
|
||||
echo "Deleting stuck dragonfly: {$dragonfly->name}\n";
|
||||
$dragonfly->forceDelete();
|
||||
DeleteResourceJob::dispatch($dragonfly);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n";
|
||||
@@ -128,7 +138,7 @@ class CleanupStuckedResources extends Command
|
||||
$clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($clickhouses as $clickhouse) {
|
||||
echo "Deleting stuck clickhouse: {$clickhouse->name}\n";
|
||||
$clickhouse->forceDelete();
|
||||
DeleteResourceJob::dispatch($clickhouse);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n";
|
||||
@@ -137,7 +147,7 @@ class CleanupStuckedResources extends Command
|
||||
$mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mongodbs as $mongodb) {
|
||||
echo "Deleting stuck mongodb: {$mongodb->name}\n";
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n";
|
||||
@@ -146,7 +156,7 @@ class CleanupStuckedResources extends Command
|
||||
$mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mysqls as $mysql) {
|
||||
echo "Deleting stuck mysql: {$mysql->name}\n";
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mysql: {$e->getMessage()}\n";
|
||||
@@ -155,7 +165,7 @@ class CleanupStuckedResources extends Command
|
||||
$mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mariadbs as $mariadb) {
|
||||
echo "Deleting stuck mariadb: {$mariadb->name}\n";
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n";
|
||||
@@ -164,7 +174,7 @@ class CleanupStuckedResources extends Command
|
||||
$services = Service::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($services as $service) {
|
||||
echo "Deleting stuck service: {$service->name}\n";
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck service: {$e->getMessage()}\n";
|
||||
@@ -217,19 +227,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($applications as $application) {
|
||||
if (! data_get($application, 'environment')) {
|
||||
echo 'Application without environment: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $application->destination()) {
|
||||
echo 'Application without destination: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($application, 'destination.server')) {
|
||||
echo 'Application without server: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -242,19 +252,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($postgresqls as $postgresql) {
|
||||
if (! data_get($postgresql, 'environment')) {
|
||||
echo 'Postgresql without environment: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $postgresql->destination()) {
|
||||
echo 'Postgresql without destination: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($postgresql, 'destination.server')) {
|
||||
echo 'Postgresql without server: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -267,19 +277,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($redis as $redis) {
|
||||
if (! data_get($redis, 'environment')) {
|
||||
echo 'Redis without environment: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $redis->destination()) {
|
||||
echo 'Redis without destination: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($redis, 'destination.server')) {
|
||||
echo 'Redis without server: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -293,19 +303,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mongodbs as $mongodb) {
|
||||
if (! data_get($mongodb, 'environment')) {
|
||||
echo 'Mongodb without environment: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mongodb->destination()) {
|
||||
echo 'Mongodb without destination: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mongodb, 'destination.server')) {
|
||||
echo 'Mongodb without server: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -319,19 +329,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mysqls as $mysql) {
|
||||
if (! data_get($mysql, 'environment')) {
|
||||
echo 'Mysql without environment: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mysql->destination()) {
|
||||
echo 'Mysql without destination: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mysql, 'destination.server')) {
|
||||
echo 'Mysql without server: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -345,19 +355,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mariadbs as $mariadb) {
|
||||
if (! data_get($mariadb, 'environment')) {
|
||||
echo 'Mariadb without environment: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mariadb->destination()) {
|
||||
echo 'Mariadb without destination: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mariadb, 'destination.server')) {
|
||||
echo 'Mariadb without server: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -371,19 +381,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($services as $service) {
|
||||
if (! data_get($service, 'environment')) {
|
||||
echo 'Service without environment: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $service->destination()) {
|
||||
echo 'Service without destination: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($service, 'server')) {
|
||||
echo 'Service without server: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -396,7 +406,7 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($serviceApplications as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -409,7 +419,7 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscription;
|
||||
use App\Actions\User\DeleteUserResources;
|
||||
use App\Actions\User\DeleteUserServers;
|
||||
use App\Actions\User\DeleteUserTeams;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CloudDeleteUser extends Command
|
||||
{
|
||||
protected $signature = 'cloud:delete-user {email}
|
||||
{--dry-run : Preview what will be deleted without actually deleting}
|
||||
{--skip-stripe : Skip Stripe subscription cancellation}
|
||||
{--skip-resources : Skip resource deletion}';
|
||||
|
||||
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
|
||||
|
||||
private bool $isDryRun = false;
|
||||
|
||||
private bool $skipStripe = false;
|
||||
|
||||
private bool $skipResources = false;
|
||||
|
||||
private User $user;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command is only available on cloud instances.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$email = $this->argument('email');
|
||||
$this->isDryRun = $this->option('dry-run');
|
||||
$this->skipStripe = $this->option('skip-stripe');
|
||||
$this->skipResources = $this->option('skip-resources');
|
||||
|
||||
if ($this->isDryRun) {
|
||||
$this->info('🔍 DRY RUN MODE - No data will be deleted');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->user = User::whereEmail($email)->firstOrFail();
|
||||
} catch (\Exception $e) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Implement file lock to prevent concurrent deletions of the same user
|
||||
$lockKey = "user_deletion_{$this->user->id}";
|
||||
$lock = Cache::lock($lockKey, 600); // 10 minute lock
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->error('Another deletion process is already running for this user. Please try again later.');
|
||||
$this->logAction("Deletion blocked for user {$email}: Another process is already running");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->logAction("Starting user deletion process for: {$email}");
|
||||
|
||||
// Phase 1: Show User Overview (outside transaction)
|
||||
if (! $this->showUserOverview()) {
|
||||
$this->info('User deletion cancelled.');
|
||||
$lock->release();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If not dry run, wrap everything in a transaction
|
||||
if (! $this->isDryRun) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at team handling phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at final phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ User deletion completed successfully!');
|
||||
$this->logAction("User deletion completed for: {$email}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('An error occurred during user deletion: '.$e->getMessage());
|
||||
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Dry run mode - just run through the phases without transaction
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
$this->info('User deletion would be cancelled at resource deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
$this->info('User deletion would be cancelled at server deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
$this->info('User deletion would be cancelled at team handling phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
$this->info('User deletion would be cancelled at final phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
} finally {
|
||||
// Ensure lock is always released
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function showUserOverview(): bool
|
||||
{
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 1: USER OVERVIEW');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$teams = $this->user->teams;
|
||||
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
|
||||
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
|
||||
|
||||
// Collect all servers from all teams
|
||||
$allServers = collect();
|
||||
$allApplications = collect();
|
||||
$allDatabases = collect();
|
||||
$allServices = collect();
|
||||
$activeSubscriptions = collect();
|
||||
|
||||
foreach ($teams as $team) {
|
||||
$servers = $team->servers;
|
||||
$allServers = $allServers->merge($servers);
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$resources = $server->definedResources();
|
||||
foreach ($resources as $resource) {
|
||||
if ($resource instanceof \App\Models\Application) {
|
||||
$allApplications->push($resource);
|
||||
} elseif ($resource instanceof \App\Models\Service) {
|
||||
$allServices->push($resource);
|
||||
} else {
|
||||
$allDatabases->push($resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($team->subscription && $team->subscription->stripe_subscription_id) {
|
||||
$activeSubscriptions->push($team->subscription);
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['User', $this->user->email],
|
||||
['User ID', $this->user->id],
|
||||
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
|
||||
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
|
||||
['Teams (Total)', $teams->count()],
|
||||
['Teams (Owner)', $ownedTeams->count()],
|
||||
['Teams (Member)', $memberTeams->count()],
|
||||
['Servers', $allServers->unique('id')->count()],
|
||||
['Applications', $allApplications->count()],
|
||||
['Databases', $allDatabases->count()],
|
||||
['Services', $allServices->count()],
|
||||
['Active Stripe Subscriptions', $activeSubscriptions->count()],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteResources(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 2: DELETE RESOURCES');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserResources($this->user, $this->isDryRun);
|
||||
$resources = $action->getResourcesPreview();
|
||||
|
||||
if ($resources['applications']->isEmpty() &&
|
||||
$resources['databases']->isEmpty() &&
|
||||
$resources['services']->isEmpty()) {
|
||||
$this->info('No resources to delete.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->info('Resources to be deleted:');
|
||||
$this->newLine();
|
||||
|
||||
if ($resources['applications']->isNotEmpty()) {
|
||||
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'UUID', 'Server', 'Status'],
|
||||
$resources['applications']->map(function ($app) {
|
||||
return [
|
||||
$app->name,
|
||||
$app->uuid,
|
||||
$app->destination->server->name,
|
||||
$app->status ?? 'unknown',
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($resources['databases']->isNotEmpty()) {
|
||||
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'Type', 'UUID', 'Server'],
|
||||
$resources['databases']->map(function ($db) {
|
||||
return [
|
||||
$db->name,
|
||||
class_basename($db),
|
||||
$db->uuid,
|
||||
$db->destination->server->name,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($resources['services']->isNotEmpty()) {
|
||||
$this->warn("Services to be deleted ({$resources['services']->count()}):");
|
||||
$this->table(
|
||||
['Name', 'UUID', 'Server'],
|
||||
$resources['services']->map(function ($service) {
|
||||
return [
|
||||
$service->name,
|
||||
$service->uuid,
|
||||
$service->server->name,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
|
||||
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting resources...');
|
||||
$result = $action->execute();
|
||||
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
|
||||
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteServers(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 3: DELETE SERVERS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserServers($this->user, $this->isDryRun);
|
||||
$servers = $action->getServersPreview();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
$this->info('No servers to delete.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("Servers to be deleted ({$servers->count()}):");
|
||||
$this->table(
|
||||
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
|
||||
$servers->map(function ($server) {
|
||||
$resourceCount = $server->definedResources()->count();
|
||||
|
||||
return [
|
||||
$server->id,
|
||||
$server->name,
|
||||
$server->ip,
|
||||
$server->description ?? '-',
|
||||
$resourceCount,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
|
||||
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting servers...');
|
||||
$result = $action->execute();
|
||||
$this->info("Deleted {$result['servers']} servers");
|
||||
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function handleTeams(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 4: HANDLE TEAMS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new DeleteUserTeams($this->user, $this->isDryRun);
|
||||
$preview = $action->getTeamsPreview();
|
||||
|
||||
// Check for edge cases first - EXIT IMMEDIATELY if found
|
||||
if ($preview['edge_cases']->isNotEmpty()) {
|
||||
$this->error('═══════════════════════════════════════');
|
||||
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
|
||||
$this->error('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($preview['edge_cases'] as $edgeCase) {
|
||||
$team = $edgeCase['team'];
|
||||
$reason = $edgeCase['reason'];
|
||||
$this->error("Team: {$team->name} (ID: {$team->id})");
|
||||
$this->error("Issue: {$reason}");
|
||||
|
||||
// Show team members for context
|
||||
$this->info('Current members:');
|
||||
foreach ($team->members as $member) {
|
||||
$role = $member->pivot->role;
|
||||
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
|
||||
}
|
||||
|
||||
// Check for active resources
|
||||
$resourceCount = 0;
|
||||
foreach ($team->servers as $server) {
|
||||
$resources = $server->definedResources();
|
||||
$resourceCount += $resources->count();
|
||||
}
|
||||
|
||||
if ($resourceCount > 0) {
|
||||
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
|
||||
}
|
||||
|
||||
// Show subscription details if relevant
|
||||
if ($team->subscription && $team->subscription->stripe_subscription_id) {
|
||||
$this->warn(' ⚠️ Active Stripe subscription details:');
|
||||
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
|
||||
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
|
||||
|
||||
// Show other owners who could potentially take over
|
||||
$otherOwners = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'owner';
|
||||
});
|
||||
|
||||
if ($otherOwners->isNotEmpty()) {
|
||||
$this->info(' Other owners who could take over billing:');
|
||||
foreach ($otherOwners as $owner) {
|
||||
$this->line(" - {$owner->name} ({$owner->email})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('Please resolve these issues manually before retrying:');
|
||||
|
||||
// Check if any edge case involves subscription payment issues
|
||||
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
|
||||
return str_contains($edgeCase['reason'], 'Stripe subscription');
|
||||
});
|
||||
|
||||
if ($hasSubscriptionIssue) {
|
||||
$this->info('For teams with subscription payment issues:');
|
||||
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
|
||||
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
|
||||
$this->info('3. Have the other owner create a new subscription after cancelling this one');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
|
||||
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
|
||||
});
|
||||
|
||||
if ($hasNoOwnerReplacement) {
|
||||
$this->info('For teams with no suitable owner replacement:');
|
||||
$this->info('1. Assign an admin role to a trusted member, OR');
|
||||
$this->info('2. Transfer team resources to another team, OR');
|
||||
$this->info('3. Delete the team manually if no longer needed');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
|
||||
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
|
||||
|
||||
// Exit immediately - don't proceed with deletion
|
||||
if (! $this->isDryRun) {
|
||||
DB::rollBack();
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($preview['to_delete']->isEmpty() &&
|
||||
$preview['to_transfer']->isEmpty() &&
|
||||
$preview['to_leave']->isEmpty()) {
|
||||
$this->info('No team changes needed.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($preview['to_delete']->isNotEmpty()) {
|
||||
$this->warn('Teams to be DELETED (user is the only member):');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Resources', 'Subscription'],
|
||||
$preview['to_delete']->map(function ($team) {
|
||||
$resourceCount = 0;
|
||||
foreach ($team->servers as $server) {
|
||||
$resourceCount += $server->definedResources()->count();
|
||||
}
|
||||
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
|
||||
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
|
||||
: 'No';
|
||||
|
||||
return [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$resourceCount,
|
||||
$hasSubscription,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($preview['to_transfer']->isNotEmpty()) {
|
||||
$this->warn('Teams where ownership will be TRANSFERRED:');
|
||||
$this->table(
|
||||
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
|
||||
$preview['to_transfer']->map(function ($item) {
|
||||
return [
|
||||
$item['team']->id,
|
||||
$item['team']->name,
|
||||
$item['new_owner']->name,
|
||||
$item['new_owner']->email,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($preview['to_leave']->isNotEmpty()) {
|
||||
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
|
||||
$userId = $this->user->id;
|
||||
$this->table(
|
||||
['ID', 'Name', 'User Role', 'Other Members'],
|
||||
$preview['to_leave']->map(function ($team) use ($userId) {
|
||||
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
|
||||
$otherMembers = $team->members->count() - 1;
|
||||
|
||||
return [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$userRole,
|
||||
$otherMembers,
|
||||
];
|
||||
})->toArray()
|
||||
);
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
|
||||
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Processing team changes...');
|
||||
$result = $action->execute();
|
||||
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
|
||||
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function cancelStripeSubscriptions(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$action = new CancelSubscription($this->user, $this->isDryRun);
|
||||
$subscriptions = $action->getSubscriptionsPreview();
|
||||
|
||||
if ($subscriptions->isEmpty()) {
|
||||
$this->info('No Stripe subscriptions to cancel.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->info('Stripe subscriptions to cancel:');
|
||||
$this->newLine();
|
||||
|
||||
$totalMonthlyValue = 0;
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$team = $subscription->team;
|
||||
$planId = $subscription->stripe_plan_id;
|
||||
|
||||
// Try to get the price from config
|
||||
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
|
||||
$totalMonthlyValue += $monthlyValue;
|
||||
|
||||
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
|
||||
if ($monthlyValue > 0) {
|
||||
$this->line(" Monthly value: \${$monthlyValue}");
|
||||
}
|
||||
if ($subscription->stripe_cancel_at_period_end) {
|
||||
$this->line(' ⚠️ Already set to cancel at period end');
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalMonthlyValue > 0) {
|
||||
$this->newLine();
|
||||
$this->warn("Total monthly value: \${$totalMonthlyValue}");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
|
||||
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Cancelling subscriptions...');
|
||||
$result = $action->execute();
|
||||
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
|
||||
if ($result['failed'] > 0 && ! empty($result['errors'])) {
|
||||
$this->error('Failed subscriptions:');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function deleteUserProfile(): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->info('PHASE 6: DELETE USER PROFILE');
|
||||
$this->info('═══════════════════════════════════════');
|
||||
$this->newLine();
|
||||
|
||||
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
|
||||
$this->newLine();
|
||||
|
||||
$this->info('User profile to be deleted:');
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['Email', $this->user->email],
|
||||
['Name', $this->user->name],
|
||||
['User ID', $this->user->id],
|
||||
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
|
||||
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
|
||||
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
|
||||
$confirmation = $this->ask('Confirmation');
|
||||
|
||||
if ($confirmation !== "DELETE {$this->user->email}") {
|
||||
$this->error('Confirmation text does not match. Deletion cancelled.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDryRun) {
|
||||
$this->info('Deleting user profile...');
|
||||
|
||||
try {
|
||||
$this->user->delete();
|
||||
$this->info('User profile deleted successfully.');
|
||||
$this->logAction("User profile deleted: {$this->user->email}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to delete user profile: '.$e->getMessage());
|
||||
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getSubscriptionMonthlyValue(string $planId): int
|
||||
{
|
||||
// Try to get pricing from subscription metadata or config
|
||||
// Since we're using dynamic pricing, return 0 for now
|
||||
// This could be enhanced by fetching the actual price from Stripe API
|
||||
|
||||
// Check if this is a dynamic pricing plan
|
||||
$dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly');
|
||||
$dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly');
|
||||
|
||||
if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) {
|
||||
// For dynamic pricing, we can't determine the exact amount without calling Stripe API
|
||||
// Return 0 to indicate dynamic/usage-based pricing
|
||||
return 0;
|
||||
}
|
||||
|
||||
// For any other plans, return 0 as we don't have hardcoded prices
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function logAction(string $message): void
|
||||
{
|
||||
$logMessage = "[CloudDeleteUser] {$message}";
|
||||
|
||||
if ($this->isDryRun) {
|
||||
$logMessage = "[DRY RUN] {$logMessage}";
|
||||
}
|
||||
|
||||
Log::channel('single')->info($logMessage);
|
||||
|
||||
// Also log to a dedicated user deletion log file
|
||||
$logFile = storage_path('logs/user-deletions.log');
|
||||
|
||||
// Ensure the logs directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudFixSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:fix-subscription
|
||||
{--fix-canceled-subs : Fix canceled subscriptions in database}
|
||||
{--verify-all : Verify all active subscriptions against Stripe}
|
||||
{--fix-verified : Fix discrepancies found during verification}
|
||||
{--dry-run : Show what would be fixed without making changes}
|
||||
{--one : Only fix the first found subscription}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fix Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
if ($this->option('verify-all')) {
|
||||
return $this->verifyAllActiveSubscriptions($stripe);
|
||||
}
|
||||
|
||||
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
|
||||
return $this->fixCanceledSubscriptions($stripe);
|
||||
}
|
||||
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'invoice_status',
|
||||
'stripe_customer_url',
|
||||
'stripe_subscription_id',
|
||||
'subscription_status',
|
||||
'subscription_url',
|
||||
'note',
|
||||
]);
|
||||
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
|
||||
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID while invoice not past_due',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $stripeSubscriptionId) {
|
||||
// No subscription ID and invoice is past_due, still record for visibility
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
$stripeSubscriptionId,
|
||||
$subscription->status,
|
||||
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
|
||||
'Subscription not active',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix canceled subscriptions in the database
|
||||
*/
|
||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$checkOne = $this->option('one');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
if ($checkOne) {
|
||||
$this->info('Checking only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking for canceled subscriptions...');
|
||||
}
|
||||
} else {
|
||||
if ($checkOne) {
|
||||
$this->info('Checking and fixing only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking and fixing canceled subscriptions...');
|
||||
}
|
||||
}
|
||||
|
||||
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$toFixCount = 0;
|
||||
$fixedCount = 0;
|
||||
$errors = [];
|
||||
$canceledSubscriptions = [];
|
||||
|
||||
foreach ($teamsWithSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'canceled',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('Would fix canceled subscription:');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->warn("Found canceled subscription for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing canceled subscription:\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'missing',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->error('Would fix missing subscription (not found in Stripe):');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
|
||||
|
||||
if ($toFixCount > 0) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-canceled-subs to apply these changes');
|
||||
}
|
||||
} else {
|
||||
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
$this->newLine();
|
||||
$this->error('Errors encountered:');
|
||||
foreach ($errors as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all active subscriptions against Stripe API
|
||||
*/
|
||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$shouldFix = $this->option('fix-verified');
|
||||
|
||||
$this->info('Verifying all active subscriptions against Stripe...');
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->warn('FIX MODE - Discrepancies will be corrected');
|
||||
}
|
||||
|
||||
// Get all teams with active subscriptions
|
||||
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$totalCount = $teamsWithActiveSubscriptions->count();
|
||||
|
||||
$this->info("Found {$totalCount} teams with active subscriptions in database");
|
||||
$this->newLine();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'team_name',
|
||||
'customer_id',
|
||||
'subscription_id',
|
||||
'db_status',
|
||||
'stripe_status',
|
||||
'action',
|
||||
'member_emails',
|
||||
'customer_url',
|
||||
'subscription_url',
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => $totalCount,
|
||||
'valid_active' => 0,
|
||||
'valid_past_due' => 0,
|
||||
'canceled' => 0,
|
||||
'missing' => 0,
|
||||
'invalid' => 0,
|
||||
'fixed' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$processedCount = 0;
|
||||
|
||||
foreach ($teamsWithActiveSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
// Database state
|
||||
$dbStatus = 'active';
|
||||
if ($subscription->stripe_past_due) {
|
||||
$dbStatus = 'past_due';
|
||||
}
|
||||
|
||||
$stripeStatus = null;
|
||||
$action = 'none';
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
|
||||
|
||||
$foundResult = null;
|
||||
$searchMethod = null;
|
||||
|
||||
// Search by customer ID
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
}
|
||||
} else {
|
||||
$this->line(' → No customer ID available');
|
||||
}
|
||||
|
||||
// Search by emails if not found
|
||||
if (! $foundResult && count($memberEmails) > 0) {
|
||||
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
|
||||
// Update customer ID if different
|
||||
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
|
||||
if ($isDryRun) {
|
||||
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
|
||||
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription'])) {
|
||||
// Check if it's an active/past_due subscription
|
||||
if (in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription, handle update
|
||||
$result = $this->handleFoundSubscription(
|
||||
$team,
|
||||
$subscription,
|
||||
$foundResult['subscription'],
|
||||
$searchMethod,
|
||||
$isDryRun,
|
||||
$shouldFix,
|
||||
$stats
|
||||
);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$result['id'],
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$result['url'],
|
||||
]);
|
||||
} else {
|
||||
// Found subscription but it's canceled/expired - needs to be deactivated
|
||||
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
|
||||
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$foundResult['subscription']->id,
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'needs_fix',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// No subscription found at all
|
||||
$this->line(' → No subscription found');
|
||||
|
||||
$stripeStatus = 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
'N/A',
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// First validate the subscription ID format
|
||||
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
|
||||
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
$stripeStatus = $stripeSubscription->status;
|
||||
|
||||
// Determine if action is needed
|
||||
switch ($stripeStatus) {
|
||||
case 'active':
|
||||
$stats['valid_active']++;
|
||||
$action = 'valid';
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
$stats['valid_past_due']++;
|
||||
$action = 'valid';
|
||||
// Ensure past_due flag is set
|
||||
if (! $subscription->stripe_past_due) {
|
||||
if ($isDryRun) {
|
||||
$this->info("Would set stripe_past_due=true for Team {$team->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_past_due' => true]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
case 'incomplete':
|
||||
$stats['canceled']++;
|
||||
$action = 'needs_fix';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $stripeStatus);
|
||||
$stats['fixed']++;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$stats['invalid']++;
|
||||
$action = 'unknown';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
$this->error(' → Error: '.$e->getMessage());
|
||||
|
||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||
// Subscription doesn't exist, try to find by customer ID
|
||||
$this->warn(" → Subscription not found, checking customer's subscriptions...");
|
||||
|
||||
$foundResult = null;
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription with different ID
|
||||
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
"WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}",
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'id_mismatch',
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundResult['subscription']->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $foundResult['status'] === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Updated subscription ID');
|
||||
}
|
||||
|
||||
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
} else {
|
||||
// No active subscription found
|
||||
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Other API error
|
||||
$stats['errors']++;
|
||||
$this->error(' → API Error - not marking as deleted');
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error: '.$e->getStripeCode(),
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Unexpected error: '.$e->getMessage());
|
||||
$stats['errors']++;
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error',
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
|
||||
}
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
// Print summary
|
||||
$this->newLine(2);
|
||||
$this->info('=== Verification Summary ===');
|
||||
$this->info("Total subscriptions checked: {$stats['total']}");
|
||||
$this->newLine();
|
||||
|
||||
$this->info('Valid subscriptions in Stripe:');
|
||||
$this->line(" - Active: {$stats['valid_active']}");
|
||||
$this->line(" - Past Due: {$stats['valid_past_due']}");
|
||||
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
|
||||
$this->info(" Total valid: {$validTotal}");
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('Invalid subscriptions:');
|
||||
$this->line(" - Canceled/Expired: {$stats['canceled']}");
|
||||
$this->line(" - Missing/Not Found: {$stats['missing']}");
|
||||
$this->line(" - Unknown status: {$stats['invalid']}");
|
||||
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
|
||||
$this->warn(" Total invalid: {$invalidTotal}");
|
||||
|
||||
if ($stats['errors'] > 0) {
|
||||
$this->newLine();
|
||||
$this->error("Errors encountered: {$stats['errors']}");
|
||||
}
|
||||
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Fixed subscriptions: {$stats['fixed']}");
|
||||
} elseif ($invalidTotal > 0 && ! $shouldFix) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-verified to fix the discrepancies');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a subscription based on its status
|
||||
*/
|
||||
private function fixSubscription($team, $subscription, $status)
|
||||
{
|
||||
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
|
||||
$message .= "Team Name: {$team->name}\n";
|
||||
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
|
||||
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
|
||||
|
||||
send_internal_notification($message);
|
||||
|
||||
// Call the team's subscription ended method which properly cleans up
|
||||
$team->subscriptionEnded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by customer ID
|
||||
*/
|
||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
||||
{
|
||||
try {
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
'customer' => $customerId,
|
||||
'limit' => 10,
|
||||
'status' => 'all',
|
||||
]);
|
||||
|
||||
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
|
||||
|
||||
// Look for active/past_due first
|
||||
foreach ($subscriptions->data as $sub) {
|
||||
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
|
||||
if (in_array($sub->status, ['active', 'past_due'])) {
|
||||
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// If not requiring active and there are subscriptions, return first one
|
||||
if (! $requireActive && count($subscriptions->data) > 0) {
|
||||
$sub = $subscriptions->data[0];
|
||||
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Error searching by customer ID: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by team member emails
|
||||
*/
|
||||
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
|
||||
{
|
||||
$this->line(' → Searching by team member emails...');
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$this->line(" → Checking email: {$email}");
|
||||
|
||||
try {
|
||||
$customers = $stripe->customers->all([
|
||||
'email' => $email,
|
||||
'limit' => 5,
|
||||
]);
|
||||
|
||||
if (count($customers->data) === 0) {
|
||||
$this->line(' - No customers found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(' - Found '.count($customers->data).' customer(s)');
|
||||
|
||||
foreach ($customers->data as $customer) {
|
||||
$this->line(" - Checking customer {$customer->id}");
|
||||
|
||||
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
|
||||
if ($result) {
|
||||
$result['method'] = "email:{$email}";
|
||||
$result['customer_id'] = $customer->id;
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle found subscription update (only for active/past_due subscriptions)
|
||||
*/
|
||||
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stripeStatus = $foundSub->status;
|
||||
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
|
||||
|
||||
// Only update if it's active or past_due
|
||||
if (! in_array($stripeStatus, ['active', 'past_due'])) {
|
||||
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
|
||||
|
||||
return [
|
||||
'id' => $foundSub->id,
|
||||
'status' => $stripeStatus,
|
||||
'action' => 'error',
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundSub->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $stripeStatus === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(" → Updated subscription ID to {$foundSub->id}");
|
||||
}
|
||||
|
||||
// Update stats
|
||||
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
|
||||
return [
|
||||
'id' => "FOUND: {$foundSub->id}",
|
||||
'status' => $stripeStatus,
|
||||
'action' => "will_update (via {$searchMethod})",
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing subscription
|
||||
*/
|
||||
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stats['missing']++;
|
||||
|
||||
if ($isDryRun) {
|
||||
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
|
||||
$this->warn(" → Would deactivate subscription - {$statusMsg}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $status);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Deactivated subscription');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'N/A',
|
||||
'status' => $status,
|
||||
'action' => 'needs_fix',
|
||||
'url' => 'N/A',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCheckSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:check-subscription';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
if (! $stripeSubscriptionId) {
|
||||
echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCleanupSubscriptions extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-subs';
|
||||
|
||||
protected $description = 'Cleanup subcriptions teams';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on cloud');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->info('Cleaning up subcriptions teams');
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
$teams = Team::all()->filter(function ($team) {
|
||||
return $team->id !== 0;
|
||||
})->sortBy('id');
|
||||
foreach ($teams as $team) {
|
||||
if ($team) {
|
||||
$this->info("Checking team {$team->id}");
|
||||
}
|
||||
if (! data_get($team, 'subscription')) {
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
}
|
||||
// If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status
|
||||
if (! (data_get($team, 'subscription.stripe_subscription_id'))) {
|
||||
$this->info("Resetting invoice paid status for team {$team->id}");
|
||||
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
|
||||
$status = data_get($subscription, 'status');
|
||||
if ($status === 'active') {
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_trial_already_ended' => false,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->info('Subscription status: '.$status);
|
||||
$this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id'));
|
||||
$confirm = $this->confirm('Do you want to cancel the subscription?', true);
|
||||
if (! $confirm) {
|
||||
$this->info("Skipping team {$team->id}");
|
||||
} else {
|
||||
$this->info("Cancelling subscription for team {$team->id}");
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function disableServers(Team $team)
|
||||
{
|
||||
foreach ($team->servers as $server) {
|
||||
if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') {
|
||||
$this->info("Disabling server {$server->id} {$server->name}");
|
||||
$server->settings()->update([
|
||||
'is_usable' => false,
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
$server->update([
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
ServerReachabilityChanged::dispatch($server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -44,5 +45,6 @@ class Dev extends Command
|
||||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
+73
-125
@@ -5,9 +5,10 @@ namespace App\Console\Commands;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
@@ -19,80 +20,18 @@ use Illuminate\Support\Facades\Http;
|
||||
|
||||
class Init extends Command
|
||||
{
|
||||
protected $signature = 'app:init {--force-cloud}';
|
||||
protected $signature = 'app:init';
|
||||
|
||||
protected $description = 'Cleanup instance related stuffs';
|
||||
|
||||
public $servers = null;
|
||||
|
||||
public InstanceSettings $settings;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->optimize();
|
||||
|
||||
if (isCloud() && ! $this->option('force-cloud')) {
|
||||
echo "Skipping init as we are on cloud and --force-cloud option is not set\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->servers = Server::all();
|
||||
if (! isCloud()) {
|
||||
$this->sendAliveSignal();
|
||||
get_public_ips();
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
$this->replaceSlashInEnvironmentName();
|
||||
$this->restoreCoolifyDbBackup();
|
||||
$this->updateUserEmails();
|
||||
//
|
||||
$this->updateTraefikLabels();
|
||||
if (! isCloud() || $this->option('force-cloud')) {
|
||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
}
|
||||
|
||||
$this->call('cleanup:redis');
|
||||
|
||||
try {
|
||||
$this->call('cleanup:names');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:names command: {$e->getMessage()}\n";
|
||||
}
|
||||
$this->call('cleanup:stucked-resources');
|
||||
|
||||
try {
|
||||
$this->pullHelperImage();
|
||||
} catch (\Throwable $e) {
|
||||
//
|
||||
}
|
||||
|
||||
if (isCloud()) {
|
||||
try {
|
||||
$this->cleanupInProgressApplicationDeployments();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pullChangelogFromGitHub();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupInProgressApplicationDeployments();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
@@ -105,20 +44,80 @@ class Init extends Command
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pullHelperImage();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in pullHelperImage command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
if (isCloud()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
$this->servers = Server::all();
|
||||
|
||||
$do_not_track = data_get($this->settings, 'do_not_track', true);
|
||||
if ($do_not_track == false) {
|
||||
$this->sendAliveSignal();
|
||||
}
|
||||
get_public_ips();
|
||||
|
||||
// Backward compatibility
|
||||
$this->replaceSlashInEnvironmentName();
|
||||
$this->restoreCoolifyDbBackup();
|
||||
$this->updateUserEmails();
|
||||
//
|
||||
$this->updateTraefikLabels();
|
||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$this->call('cleanup:names');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:names command: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$this->call('cleanup:stucked-resources');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])->update([
|
||||
'status' => ApplicationDeploymentStatus::FAILED->value,
|
||||
]);
|
||||
|
||||
if ($updatedCount > 0) {
|
||||
echo "Marked {$updatedCount} stuck deployments as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
$localhost->setupDynamicProxyConfiguration();
|
||||
if ($localhost) {
|
||||
$localhost->setupDynamicProxyConfiguration();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
|
||||
if (! is_null(config('constants.coolify.autoupdate', null))) {
|
||||
if (config('constants.coolify.autoupdate') == true) {
|
||||
echo "Enabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => true]);
|
||||
$this->settings->update(['is_auto_update_enabled' => true]);
|
||||
} else {
|
||||
echo "Disabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => false]);
|
||||
$this->settings->update(['is_auto_update_enabled' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,24 +139,18 @@ class Init extends Command
|
||||
private function pullChangelogFromGitHub()
|
||||
{
|
||||
try {
|
||||
PullChangelogFromGitHub::dispatch();
|
||||
PullChangelog::dispatch();
|
||||
echo "Changelog fetch initiated\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function optimize()
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
}
|
||||
|
||||
private function updateUserEmails()
|
||||
{
|
||||
try {
|
||||
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
|
||||
$user->update(['email' => strtolower($user->email)]);
|
||||
$user->update(['email' => $user->email]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in updating user emails: {$e->getMessage()}\n";
|
||||
@@ -173,27 +166,6 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupUnnecessaryDynamicProxyConfiguration()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
try {
|
||||
if (! $server->isFunctional()) {
|
||||
continue;
|
||||
}
|
||||
if ($server->id === 0) {
|
||||
continue;
|
||||
}
|
||||
$file = $server->proxyPath().'/dynamic/coolify.yaml';
|
||||
|
||||
return instant_remote_process([
|
||||
"rm -f $file",
|
||||
], $server, false);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupUnusedNetworkFromCoolifyProxy()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
@@ -263,13 +235,6 @@ class Init extends Command
|
||||
{
|
||||
$id = config('app.id');
|
||||
$version = config('constants.coolify.version');
|
||||
$settings = instanceSettings();
|
||||
$do_not_track = data_get($settings, 'do_not_track');
|
||||
if ($do_not_track == true) {
|
||||
echo "Do_not_track is enabled\n";
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
|
||||
} catch (\Throwable $e) {
|
||||
@@ -277,23 +242,6 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupInProgressApplicationDeployments()
|
||||
{
|
||||
// Cleanup any failed deployments
|
||||
try {
|
||||
if (isCloud()) {
|
||||
return;
|
||||
}
|
||||
$queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get();
|
||||
foreach ($queued_inprogress_deployments as $deployment) {
|
||||
$deployment->status = ApplicationDeploymentStatus::FAILED->value;
|
||||
$deployment->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceSlashInEnvironmentName()
|
||||
{
|
||||
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InitChangelog extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'changelog:init {month? : Month in YYYY-MM format (defaults to current month)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Initialize a new monthly changelog file with example structure';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$month = $this->argument('month') ?: Carbon::now()->format('Y-m');
|
||||
|
||||
// Validate month format
|
||||
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
||||
$this->error('Invalid month format. Use YYYY-MM format with valid months 01-12 (e.g., 2025-08)');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$changelogsDir = base_path('changelogs');
|
||||
$filePath = $changelogsDir."/{$month}.json";
|
||||
|
||||
// Create changelogs directory if it doesn't exist
|
||||
if (! is_dir($changelogsDir)) {
|
||||
mkdir($changelogsDir, 0755, true);
|
||||
$this->info("Created changelogs directory: {$changelogsDir}");
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if (file_exists($filePath)) {
|
||||
if (! $this->confirm("File {$month}.json already exists. Overwrite?")) {
|
||||
$this->info('Operation cancelled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the month for example data
|
||||
$carbonMonth = Carbon::createFromFormat('Y-m', $month);
|
||||
$monthName = $carbonMonth->format('F Y');
|
||||
$sampleDate = $carbonMonth->addDays(14)->toISOString(); // Mid-month
|
||||
|
||||
// Get version from config
|
||||
$version = 'v'.config('constants.coolify.version');
|
||||
|
||||
// Create example changelog structure
|
||||
$exampleData = [
|
||||
'entries' => [
|
||||
[
|
||||
'version' => $version,
|
||||
'title' => 'Example Feature Release',
|
||||
'content' => "This is an example changelog entry for {$monthName}. Replace this with your actual release notes. Include details about new features, improvements, bug fixes, and any breaking changes.",
|
||||
'published_at' => $sampleDate,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Write the file
|
||||
$jsonContent = json_encode($exampleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if (file_put_contents($filePath, $jsonContent) === false) {
|
||||
$this->error("Failed to create changelog file: {$filePath}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("✅ Created changelog file: changelogs/{$month}.json");
|
||||
$this->line(" Example entry created for {$monthName}");
|
||||
$this->line(' Edit the file to add your actual changelog entries');
|
||||
|
||||
// Show the file contents
|
||||
if ($this->option('verbose')) {
|
||||
$this->newLine();
|
||||
$this->line('File contents:');
|
||||
$this->line($jsonContent);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@ use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
@@ -103,19 +110,79 @@ class ServicesDelete extends Command
|
||||
|
||||
private function deleteDatabase()
|
||||
{
|
||||
$databases = StandalonePostgresql::all();
|
||||
if ($databases->count() === 0) {
|
||||
// Collect all databases from all types with unique identifiers
|
||||
$allDatabases = collect();
|
||||
$databaseOptions = collect();
|
||||
|
||||
// Add PostgreSQL databases
|
||||
foreach (StandalonePostgresql::all() as $db) {
|
||||
$key = "postgresql_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (PostgreSQL)");
|
||||
}
|
||||
|
||||
// Add MySQL databases
|
||||
foreach (StandaloneMysql::all() as $db) {
|
||||
$key = "mysql_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (MySQL)");
|
||||
}
|
||||
|
||||
// Add MariaDB databases
|
||||
foreach (StandaloneMariadb::all() as $db) {
|
||||
$key = "mariadb_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (MariaDB)");
|
||||
}
|
||||
|
||||
// Add MongoDB databases
|
||||
foreach (StandaloneMongodb::all() as $db) {
|
||||
$key = "mongodb_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (MongoDB)");
|
||||
}
|
||||
|
||||
// Add Redis databases
|
||||
foreach (StandaloneRedis::all() as $db) {
|
||||
$key = "redis_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (Redis)");
|
||||
}
|
||||
|
||||
// Add KeyDB databases
|
||||
foreach (StandaloneKeydb::all() as $db) {
|
||||
$key = "keydb_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (KeyDB)");
|
||||
}
|
||||
|
||||
// Add Dragonfly databases
|
||||
foreach (StandaloneDragonfly::all() as $db) {
|
||||
$key = "dragonfly_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (Dragonfly)");
|
||||
}
|
||||
|
||||
// Add ClickHouse databases
|
||||
foreach (StandaloneClickhouse::all() as $db) {
|
||||
$key = "clickhouse_{$db->id}";
|
||||
$allDatabases->put($key, $db);
|
||||
$databaseOptions->put($key, "{$db->name} (ClickHouse)");
|
||||
}
|
||||
|
||||
if ($allDatabases->count() === 0) {
|
||||
$this->error('There are no databases to delete.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$databasesToDelete = multiselect(
|
||||
'What database do you want to delete?',
|
||||
$databases->pluck('name', 'id')->sortKeys(),
|
||||
$databaseOptions->sortKeys(),
|
||||
);
|
||||
|
||||
foreach ($databasesToDelete as $database) {
|
||||
$toDelete = $databases->where('id', $database)->first();
|
||||
foreach ($databasesToDelete as $databaseKey) {
|
||||
$toDelete = $allDatabases->get($databaseKey);
|
||||
if ($toDelete) {
|
||||
$this->info($toDelete);
|
||||
$confirmed = confirm('Are you sure you want to delete all selected resources?');
|
||||
|
||||
@@ -16,7 +16,7 @@ class SyncBunny extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -25,6 +25,50 @@ class SyncBunny extends Command
|
||||
*/
|
||||
protected $description = 'Sync files to BunnyCDN';
|
||||
|
||||
/**
|
||||
* Fetch GitHub releases and sync to CDN
|
||||
*/
|
||||
private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn)
|
||||
{
|
||||
$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()) {
|
||||
$releases = $response->json();
|
||||
|
||||
// Save releases to a temporary file
|
||||
$releases_file = "$parent_dir/releases.json";
|
||||
file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// Upload to CDN
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
$pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"),
|
||||
$pool->purge("$bunny_cdn/coolify/releases.json"),
|
||||
]);
|
||||
|
||||
// Clean up temporary file
|
||||
unlink($releases_file);
|
||||
|
||||
$this->info('releases.json uploaded & purged...');
|
||||
$this->info('Total releases synced: '.count($releases));
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error fetching releases: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
@@ -33,6 +77,7 @@ class SyncBunny extends Command
|
||||
$that = $this;
|
||||
$only_template = $this->option('templates');
|
||||
$only_version = $this->option('release');
|
||||
$only_github_releases = $this->option('github-releases');
|
||||
$nightly = $this->option('nightly');
|
||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||
$bunny_cdn_path = 'coolify';
|
||||
@@ -90,7 +135,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_template && ! $only_version && ! $only_github_releases) {
|
||||
if ($nightly) {
|
||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
} else {
|
||||
@@ -128,12 +173,29 @@ class SyncBunny extends Command
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First sync GitHub releases
|
||||
$this->info('Syncing GitHub releases first...');
|
||||
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
|
||||
|
||||
// Then sync versions.json
|
||||
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...');
|
||||
|
||||
return;
|
||||
} elseif ($only_github_releases) {
|
||||
$this->info('About to sync GitHub releases to BunnyCDN.');
|
||||
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the reusable function
|
||||
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
@@ -68,7 +68,7 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||
$this->scheduleInstance->job(new PullChangelogFromGitHub)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||
$this->scheduleInstance->job(new PullChangelog)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
|
||||
$this->scheduleUpdates();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ApplicationConfigurationChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public function __construct($teamId = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -110,9 +111,14 @@ class Handler extends ExceptionHandler
|
||||
);
|
||||
}
|
||||
);
|
||||
// Check for errors that should not be reported to Sentry
|
||||
if (str($e->getMessage())->contains('No space left on device')) {
|
||||
// Log locally but don't send to Sentry
|
||||
logger()->warning('Disk space error: '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Integration::captureUnhandledException($e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception that should not be reported to Sentry or other error tracking services.
|
||||
* Use this for known, expected errors that don't require external tracking.
|
||||
*/
|
||||
class NonReportableException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new non-reportable exception instance.
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
*/
|
||||
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from another exception, preserving its message and stack trace.
|
||||
*/
|
||||
public static function fromException(\Throwable $exception): static
|
||||
{
|
||||
return new static($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Helpers;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class SshMultiplexingHelper
|
||||
@@ -30,6 +32,7 @@ class SshMultiplexingHelper
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
|
||||
// Check if connection exists
|
||||
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
@@ -41,6 +44,24 @@ class SshMultiplexingHelper
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
// Connection exists, ensure we have metadata for age tracking
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
// Existing connection but no metadata, store current time as fallback
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
|
||||
// Connection exists, check if it needs refresh due to age
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
// Perform health check if enabled
|
||||
if (config('constants.ssh.mux_health_check_enabled')) {
|
||||
if (! self::isConnectionHealthy($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,6 +86,9 @@ class SshMultiplexingHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store connection metadata for tracking
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,6 +103,9 @@ class SshMultiplexingHelper
|
||||
}
|
||||
$closeCommand .= "{$server->user}@{$server->ip}";
|
||||
Process::run($closeCommand);
|
||||
|
||||
// Clear connection metadata from cache
|
||||
self::clearConnectionMetadata($server);
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest)
|
||||
@@ -94,8 +121,18 @@ class SshMultiplexingHelper
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= '-6 ';
|
||||
}
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// Continue without multiplexing
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
@@ -130,8 +167,16 @@ class SshMultiplexingHelper
|
||||
|
||||
$ssh_command = "timeout $timeout ssh ";
|
||||
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
$multiplexingSuccessful = false;
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
||||
if ($multiplexingSuccessful) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Continue without multiplexing
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
@@ -186,4 +231,81 @@ class SshMultiplexingHelper
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the multiplexed connection is healthy by running a test command
|
||||
*/
|
||||
public static function isConnectionHealthy(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||
|
||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
|
||||
return $isHealthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connection has exceeded its maximum age
|
||||
*/
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the age of the current connection in seconds
|
||||
*/
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
$connectionTime = Cache::get($cacheKey);
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a multiplexed connection by closing and re-establishing it
|
||||
*/
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
// Close existing connection
|
||||
self::removeMuxFile($server);
|
||||
|
||||
// Establish new connection
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store connection metadata when a new connection is established
|
||||
*/
|
||||
private static function storeConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear connection metadata from cache
|
||||
*/
|
||||
private static function clearConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Traits\SshRetryable;
|
||||
|
||||
/**
|
||||
* Helper class to use SshRetryable trait in non-class contexts
|
||||
*/
|
||||
class SshRetryHandler
|
||||
{
|
||||
use SshRetryable;
|
||||
|
||||
/**
|
||||
* Static method to get a singleton instance
|
||||
*/
|
||||
public static function instance(): self
|
||||
{
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new self;
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience static method for retry execution
|
||||
*/
|
||||
public static function retry(callable $callback, array $context = [], bool $throwError = true)
|
||||
{
|
||||
return self::instance()->executeWithSshRetry($callback, $context, $throwError);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -217,6 +218,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_public_application(Request $request)
|
||||
@@ -310,6 +340,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -337,6 +368,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_private_gh_app_application(Request $request)
|
||||
@@ -430,6 +490,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -457,6 +518,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_private_deploy_key_application(Request $request)
|
||||
@@ -534,6 +624,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -561,6 +652,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_dockerfile_application(Request $request)
|
||||
@@ -635,6 +755,7 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
|
||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -662,6 +783,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_dockerimage_application(Request $request)
|
||||
@@ -699,6 +849,7 @@ class ApplicationsController extends Controller
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
|
||||
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -726,6 +877,35 @@ class ApplicationsController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_dockercompose_application(Request $request)
|
||||
@@ -746,7 +926,7 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', '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'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', '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'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
@@ -1380,7 +1560,7 @@ class ApplicationsController extends Controller
|
||||
'domains' => data_get($application, 'domains'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockercompose') {
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -1810,6 +1990,7 @@ class ApplicationsController extends Controller
|
||||
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
|
||||
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -1843,6 +2024,35 @@ class ApplicationsController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts detected.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
|
||||
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
|
||||
'conflicts' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'domain' => ['type' => 'string', 'example' => 'example.com'],
|
||||
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
|
||||
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
|
||||
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
|
||||
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_by_uuid(Request $request)
|
||||
@@ -1866,7 +2076,7 @@ class ApplicationsController extends Controller
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'docker_compose_location', 'docker_compose_raw', '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'];
|
||||
$allowedFields = ['name', 'description', 'is_static', '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', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'docker_compose_location', 'docker_compose_raw', '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'];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
@@ -1982,14 +2192,23 @@ class ApplicationsController extends Controller
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) {
|
||||
// Check for domain conflicts
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
|
||||
if (isset($result['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'domains' => 'One of the domain is already used.',
|
||||
],
|
||||
'errors' => ['domains' => $result['error']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// If there are conflicts and force is not enabled, return warning
|
||||
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
$dockerComposeDomainsJson = collect();
|
||||
@@ -2065,6 +2284,9 @@ class ApplicationsController extends Controller
|
||||
data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson));
|
||||
}
|
||||
$application->fill($data);
|
||||
if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
}
|
||||
$application->save();
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -2207,7 +2429,6 @@ class ApplicationsController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -2248,7 +2469,7 @@ class ApplicationsController extends Controller
|
||||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
@@ -2273,7 +2494,6 @@ class ApplicationsController extends Controller
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -2294,16 +2514,12 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
$is_preview = $request->is_preview ?? false;
|
||||
$is_build_time = $request->is_build_time ?? false;
|
||||
$is_literal = $request->is_literal ?? false;
|
||||
$key = str($request->key)->trim()->replace(' ', '_')->value;
|
||||
if ($is_preview) {
|
||||
$env = $application->environment_variables_preview->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $request->value;
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
@@ -2316,6 +2532,12 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
|
||||
$env->is_runtime = $request->is_runtime;
|
||||
}
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
@@ -2328,9 +2550,6 @@ class ApplicationsController extends Controller
|
||||
$env = $application->environment_variables->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $request->value;
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
@@ -2343,6 +2562,12 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
|
||||
$env->is_runtime = $request->is_runtime;
|
||||
}
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
@@ -2397,7 +2622,6 @@ class ApplicationsController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -2468,7 +2692,7 @@ class ApplicationsController extends Controller
|
||||
], 400);
|
||||
}
|
||||
$bulk_data = collect($bulk_data)->map(function ($item) {
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']);
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
|
||||
});
|
||||
$returnedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
@@ -2476,7 +2700,6 @@ class ApplicationsController extends Controller
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -2488,7 +2711,6 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
$is_preview = $item->get('is_preview') ?? false;
|
||||
$is_build_time = $item->get('is_build_time') ?? false;
|
||||
$is_literal = $item->get('is_literal') ?? false;
|
||||
$is_multi_line = $item->get('is_multiline') ?? false;
|
||||
$is_shown_once = $item->get('is_shown_once') ?? false;
|
||||
@@ -2497,9 +2719,7 @@ class ApplicationsController extends Controller
|
||||
$env = $application->environment_variables_preview->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
@@ -2509,16 +2729,23 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
|
||||
$env->is_runtime = $item->get('is_runtime');
|
||||
}
|
||||
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
|
||||
$env->is_buildtime = $item->get('is_buildtime');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
$env = $application->environment_variables()->create([
|
||||
'key' => $item->get('key'),
|
||||
'value' => $item->get('value'),
|
||||
'is_preview' => $is_preview,
|
||||
'is_build_time' => $is_build_time,
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_runtime' => $item->get('is_runtime', true),
|
||||
'is_buildtime' => $item->get('is_buildtime', true),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2527,9 +2754,6 @@ class ApplicationsController extends Controller
|
||||
$env = $application->environment_variables->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
if ($env->is_build_time != $is_build_time) {
|
||||
$env->is_build_time = $is_build_time;
|
||||
}
|
||||
if ($env->is_literal != $is_literal) {
|
||||
$env->is_literal = $is_literal;
|
||||
}
|
||||
@@ -2539,16 +2763,23 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
|
||||
$env->is_runtime = $item->get('is_runtime');
|
||||
}
|
||||
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
|
||||
$env->is_buildtime = $item->get('is_buildtime');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
$env = $application->environment_variables()->create([
|
||||
'key' => $item->get('key'),
|
||||
'value' => $item->get('value'),
|
||||
'is_preview' => $is_preview,
|
||||
'is_build_time' => $is_build_time,
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_runtime' => $item->get('is_runtime', true),
|
||||
'is_buildtime' => $item->get('is_buildtime', true),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2592,7 +2823,6 @@ class ApplicationsController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -2632,7 +2862,7 @@ class ApplicationsController extends Controller
|
||||
)]
|
||||
public function create_env(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
@@ -2652,7 +2882,6 @@ class ApplicationsController extends Controller
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -2686,10 +2915,11 @@ class ApplicationsController extends Controller
|
||||
'key' => $request->key,
|
||||
'value' => $request->value,
|
||||
'is_preview' => $request->is_preview ?? false,
|
||||
'is_build_time' => $request->is_build_time ?? false,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2709,10 +2939,11 @@ class ApplicationsController extends Controller
|
||||
'key' => $request->key,
|
||||
'value' => $request->value,
|
||||
'is_preview' => $request->is_preview ?? false,
|
||||
'is_build_time' => $request->is_build_time ?? false,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -3102,131 +3333,6 @@ class ApplicationsController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
// #[OA\Post(
|
||||
// summary: 'Execute Command',
|
||||
// description: "Execute a command on the application's current container.",
|
||||
// path: '/applications/{uuid}/execute',
|
||||
// operationId: 'execute-command-application',
|
||||
// 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',
|
||||
// format: 'uuid',
|
||||
// )
|
||||
// ),
|
||||
// ],
|
||||
// requestBody: new OA\RequestBody(
|
||||
// required: true,
|
||||
// description: 'Command to execute.',
|
||||
// content: new OA\MediaType(
|
||||
// mediaType: 'application/json',
|
||||
// schema: new OA\Schema(
|
||||
// type: 'object',
|
||||
// properties: [
|
||||
// 'command' => ['type' => 'string', 'description' => 'Command to execute.'],
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// responses: [
|
||||
// new OA\Response(
|
||||
// response: 200,
|
||||
// description: "Execute a command on the application's current container.",
|
||||
// content: [
|
||||
// new OA\MediaType(
|
||||
// mediaType: 'application/json',
|
||||
// schema: new OA\Schema(
|
||||
// type: 'object',
|
||||
// properties: [
|
||||
// 'message' => ['type' => 'string', 'example' => 'Command executed.'],
|
||||
// 'response' => ['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 execute_command_by_uuid(Request $request)
|
||||
// {
|
||||
// // TODO: Need to review this from security perspective, to not allow arbitrary command execution
|
||||
// $allowedFields = ['command'];
|
||||
// $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);
|
||||
// }
|
||||
// $return = validateIncomingRequest($request);
|
||||
// if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
// return $return;
|
||||
// }
|
||||
// $validator = customApiValidator($request->all(), [
|
||||
// 'command' => 'string|required',
|
||||
// ]);
|
||||
|
||||
// $extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
// 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);
|
||||
// }
|
||||
|
||||
// $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
|
||||
// $status = getContainerStatus($application->destination->server, $container['Names']);
|
||||
|
||||
// if ($status !== 'running') {
|
||||
// return response()->json([
|
||||
// 'message' => 'Application is not running.',
|
||||
// ], 400);
|
||||
// }
|
||||
|
||||
// $commands = collect([
|
||||
// executeInDocker($container['Names'], $request->command),
|
||||
// ]);
|
||||
|
||||
// $res = instant_remote_process(command: $commands, server: $application->destination->server);
|
||||
|
||||
// return response()->json([
|
||||
// 'message' => 'Command executed.',
|
||||
// 'response' => $res,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
private function validateDataApplications(Request $request, Server $server)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
@@ -3274,11 +3380,12 @@ class ApplicationsController extends Controller
|
||||
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
|
||||
$errors = [];
|
||||
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
|
||||
$domain = trim($domain);
|
||||
if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
|
||||
$errors[] = 'Invalid domain: '.$domain;
|
||||
}
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
if (count($errors) > 0) {
|
||||
return response()->json([
|
||||
@@ -3286,14 +3393,23 @@ class ApplicationsController extends Controller
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) {
|
||||
// Check for domain conflicts
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
|
||||
if (isset($result['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'domains' => 'One of the domain is already used.',
|
||||
],
|
||||
'errors' => ['domains' => $result['error']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// If there are conflicts and force is not enabled, return warning
|
||||
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,15 @@ use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Enums\NewDatabaseTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
@@ -79,13 +83,88 @@ class DatabasesController extends Controller
|
||||
foreach ($projects as $project) {
|
||||
$databases = $databases->merge($project->databases());
|
||||
}
|
||||
$databases = $databases->map(function ($database) {
|
||||
|
||||
$databaseIds = $databases->pluck('id')->toArray();
|
||||
|
||||
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
|
||||
->whereIn('database_id', $databaseIds)
|
||||
->get()
|
||||
->groupBy('database_id');
|
||||
|
||||
$databases = $databases->map(function ($database) use ($backupConfigs) {
|
||||
$database->backup_configs = $backupConfigs->get($database->id, collect())->values();
|
||||
|
||||
return $this->removeSensitiveData($database);
|
||||
});
|
||||
|
||||
return response()->json($databases);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get',
|
||||
description: 'Get backups details by database UUID.',
|
||||
path: '/databases/{uuid}/backups',
|
||||
operationId: 'get-database-backups-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',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all backups for a database',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
example: 'Content is very complex. Will be implemented later.',
|
||||
),
|
||||
),
|
||||
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 database_backup_details_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
|
||||
|
||||
return response()->json($backupConfig);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get',
|
||||
description: 'Get database by UUID.',
|
||||
@@ -248,6 +327,7 @@ class DatabasesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// this check if the request is a valid json
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
@@ -499,7 +579,8 @@ class DatabasesController extends Controller
|
||||
$whatToDoWithDatabaseProxy = 'start';
|
||||
}
|
||||
|
||||
$database->update($request->all());
|
||||
// Only update database fields, not backup configuration
|
||||
$database->update($request->only($allowedFields));
|
||||
|
||||
if ($whatToDoWithDatabaseProxy === 'start') {
|
||||
StartDatabaseProxy::dispatch($database);
|
||||
@@ -512,6 +593,197 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update',
|
||||
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
|
||||
operationId: 'update-database-backup',
|
||||
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: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the backup configuration.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Database backup configuration data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Database backup configuration updated',
|
||||
),
|
||||
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 update_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
// this check if the request is a valid json
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'save_s3' => 'boolean',
|
||||
'backup_now' => 'boolean|nullable',
|
||||
'enabled' => 'boolean',
|
||||
'dump_all' => 'boolean',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$uuid = $request->uuid;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
if (! $backupConfig) {
|
||||
return response()->json(['message' => 'Backup config not found.'], 404);
|
||||
}
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
|
||||
if (! 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);
|
||||
}
|
||||
|
||||
$backupData = $request->only($backupConfigFields);
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
}
|
||||
|
||||
$backupConfig->update($backupData);
|
||||
|
||||
if ($request->backup_now) {
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database backup configuration updated',
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create (PostgreSQL)',
|
||||
description: 'Create a new PostgreSQL database.',
|
||||
@@ -1630,6 +1902,344 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete backup configuration',
|
||||
description: 'Deletes a backup configuration and all its executions.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
|
||||
operationId: 'delete-backup-configuration-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration to delete',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'delete_s3',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Whether to delete all backup files from S3',
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Backup configuration deleted.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup configuration not found.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_backup_by_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
// Get all executions for this backup configuration
|
||||
$executions = $backup->executions()->get();
|
||||
|
||||
// Delete all execution files (locally and optionally from S3)
|
||||
foreach ($executions as $execution) {
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
}
|
||||
|
||||
// Delete the backup configuration itself
|
||||
$backup->delete();
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup configuration and all executions deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete backup execution',
|
||||
description: 'Deletes a specific backup execution.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}',
|
||||
operationId: 'delete-backup-execution-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'execution_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup execution to delete',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'delete_s3',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Whether to delete the backup from S3',
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Backup execution deleted.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup execution not found.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_execution_by_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
if (! $request->execution_uuid) {
|
||||
return response()->json(['message' => 'Execution UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
// Find the specific execution
|
||||
$execution = $backup->executions()->where('uuid', $request->execution_uuid)->first();
|
||||
if (! $execution) {
|
||||
return response()->json(['message' => 'Backup execution not found.'], 404);
|
||||
}
|
||||
|
||||
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
try {
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup execution deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List backup executions',
|
||||
description: 'Get all executions for a specific backup configuration.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
|
||||
operationId: 'list-backup-executions',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of backup executions',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'executions' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'filename' => ['type' => 'string'],
|
||||
'size' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'message' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup configuration not found.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_backup_executions(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
// Get all executions for this backup configuration
|
||||
$executions = $backup->executions()
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->map(function ($execution) {
|
||||
return [
|
||||
'uuid' => $execution->uuid,
|
||||
'filename' => $execution->filename,
|
||||
'size' => $execution->size,
|
||||
'created_at' => $execution->created_at->toIso8601String(),
|
||||
'message' => $execution->message,
|
||||
'status' => $execution->status,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'executions' => $executions,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start',
|
||||
description: 'Start database. `Post` request is also accepted.',
|
||||
|
||||
@@ -225,6 +225,14 @@ class DeployController extends Controller
|
||||
foreach ($uuids as $uuid) {
|
||||
$resource = getResourceByUuid($uuid, $teamId);
|
||||
if ($resource) {
|
||||
if ($pr !== 0) {
|
||||
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
|
||||
if (! $preview) {
|
||||
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class GithubController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
summary: 'Create GitHub App',
|
||||
description: 'Create a new GitHub app.',
|
||||
path: '/github-apps',
|
||||
operationId: 'create-github-app',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'GitHub app creation payload.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
|
||||
'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'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'GitHub app created successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'app_id' => ['type' => 'integer'],
|
||||
'installation_id' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string'],
|
||||
'private_key_id' => ['type' => 'integer'],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_github_app(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => 'required|string|url',
|
||||
'html_url' => 'required|string|url',
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
'app_id' => 'required|integer',
|
||||
'installation_id' => 'required|integer',
|
||||
'client_id' => 'required|string|max:255',
|
||||
'client_secret' => 'required|string',
|
||||
'webhook_secret' => 'required|string',
|
||||
'private_key_uuid' => 'required|string',
|
||||
'is_system_wide' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the private key belongs to the team
|
||||
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
|
||||
->where('team_id', $teamId)
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
'app_id' => $request->input('app_id'),
|
||||
'installation_id' => $request->input('installation_id'),
|
||||
'client_id' => $request->input('client_id'),
|
||||
'client_secret' => $request->input('client_secret'),
|
||||
'webhook_secret' => $request->input('webhook_secret'),
|
||||
'private_key_id' => $privateKey->id,
|
||||
'is_public' => false,
|
||||
'team_id' => $teamId,
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$payload['is_system_wide'] = $request->input('is_system_wide', false);
|
||||
}
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories',
|
||||
summary: 'Load Repositories for a GitHub App',
|
||||
description: 'Fetch repositories from GitHub for a given GitHub app.',
|
||||
operationId: 'load-repositories',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Repositories loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'repositories' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_repositories($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$repositories = collect();
|
||||
$page = 1;
|
||||
$maxPages = 100; // Safety limit: max 10,000 repositories
|
||||
|
||||
while ($page <= $maxPages) {
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/installation/repositories', [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => $response->json()['message'] ?? 'Failed to load repositories',
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$repos = $json['repositories'] ?? [];
|
||||
|
||||
if (empty($repos)) {
|
||||
break; // No more repositories to load
|
||||
}
|
||||
|
||||
$repositories = $repositories->concat($repos);
|
||||
$page++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'repositories' => $repositories->sortBy('name')->values(),
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
|
||||
summary: 'Load Branches for a GitHub Repository',
|
||||
description: 'Fetch branches from GitHub for a given repository.',
|
||||
operationId: 'load-branches',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'owner',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository owner'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'repo',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository name'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Branches loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'branches' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_branches($github_app_id, $owner, $repo)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$owner}/{$repo}/branches");
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => 'Error loading branches from GitHub.',
|
||||
'error' => $response->json('message'),
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$branches = $response->json();
|
||||
|
||||
return response()->json([
|
||||
'branches' => $branches,
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a GitHub app.
|
||||
*/
|
||||
#[OA\Patch(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'updateGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Update GitHub App',
|
||||
description: 'Update an existing GitHub app.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app updated successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
|
||||
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function update_github_app(Request $request, $github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$allowedFields[] = 'is_system_wide';
|
||||
}
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = 'url';
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$rules['html_url'] = 'url';
|
||||
}
|
||||
if (isset($payload['custom_user'])) {
|
||||
$rules['custom_user'] = 'string';
|
||||
}
|
||||
if (isset($payload['custom_port'])) {
|
||||
$rules['custom_port'] = 'integer|min:1|max:65535';
|
||||
}
|
||||
if (isset($payload['app_id'])) {
|
||||
$rules['app_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['installation_id'])) {
|
||||
$rules['installation_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['client_id'])) {
|
||||
$rules['client_id'] = 'string';
|
||||
}
|
||||
if (isset($payload['client_secret'])) {
|
||||
$rules['client_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['webhook_secret'])) {
|
||||
$rules['webhook_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$rules['private_key_uuid'] = 'string|uuid';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
}
|
||||
|
||||
$validator = customApiValidator($payload, $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation error',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
->where('uuid', $payload['private_key_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team',
|
||||
], 404);
|
||||
}
|
||||
|
||||
unset($payload['private_key_uuid']);
|
||||
$payload['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a GitHub app.
|
||||
*/
|
||||
#[OA\Delete(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'deleteGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Delete GitHub App',
|
||||
description: 'Delete a GitHub app if it\'s not being used by any applications.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app deleted successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Conflict - GitHub app is in use',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_github_app($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
$count = $githubApp->applications->count();
|
||||
|
||||
return response()->json([
|
||||
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
|
||||
], 409);
|
||||
}
|
||||
|
||||
$githubApp->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,6 @@ class ServicesController extends Controller
|
||||
'value' => $generatedValue,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
});
|
||||
@@ -919,7 +918,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -975,7 +973,6 @@ class ServicesController extends Controller
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -1039,7 +1036,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -1105,7 +1101,6 @@ class ServicesController extends Controller
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -1161,7 +1156,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
|
||||
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
|
||||
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
|
||||
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
|
||||
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
|
||||
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
|
||||
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
|
||||
@@ -1216,7 +1210,6 @@ class ServicesController extends Controller
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
|
||||
@@ -179,6 +179,8 @@ class TeamController extends Controller
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
@@ -264,6 +266,8 @@ class TeamController extends Controller
|
||||
$team = auth()->user()->currentTeam();
|
||||
$team->members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ApplicationPullRequestUpdateJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -78,6 +79,7 @@ class Github extends Controller
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
}
|
||||
if (! $branch) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
@@ -95,151 +97,168 @@ class Github extends Controller
|
||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
]);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
}
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$found->delete();
|
||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +346,7 @@ class Github extends Controller
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
}
|
||||
if (! $id || ! $branch) {
|
||||
return response('Nothing to do. No id or branch found.');
|
||||
@@ -344,127 +364,147 @@ class Github extends Controller
|
||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
]);
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'details' => [
|
||||
'changed_files' => $changed_files,
|
||||
'watch_paths' => $paths,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (! in_array($author_association, $trustedAssociations)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: false,
|
||||
commit: data_get($payload, 'head.sha', 'HEAD'),
|
||||
is_webhook: true,
|
||||
git_type: 'github'
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
$container_name = data_get($container, 'Names');
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
});
|
||||
}
|
||||
|
||||
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
$container_name = data_get($container, 'Names');
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
});
|
||||
}
|
||||
|
||||
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
||||
$found->delete();
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'No preview deployment found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,12 @@ namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Models\Webhook;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Stripe extends Controller
|
||||
{
|
||||
protected $webhook;
|
||||
|
||||
public function events(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -40,19 +37,10 @@ class Stripe extends Controller
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
}
|
||||
$this->webhook = Webhook::create([
|
||||
'type' => 'stripe',
|
||||
'payload' => $request->getContent(),
|
||||
]);
|
||||
StripeProcessJob::dispatch($event);
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
} catch (Exception $e) {
|
||||
$this->webhook->update([
|
||||
'status' => 'failed',
|
||||
'failure_reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ApiAllowed
|
||||
$allowedIps = array_map('trim', $allowedIps);
|
||||
$allowedIps = array_filter($allowedIps); // Remove empty entries
|
||||
|
||||
if (! empty($allowedIps) && ! check_ip_against_allowlist($request->ip(), $allowedIps)) {
|
||||
if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) {
|
||||
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,15 @@ class CanAccessTerminal
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! auth()->check()) {
|
||||
abort(401, 'Authentication required');
|
||||
}
|
||||
|
||||
// Only admins/owners can access terminal functionality
|
||||
if (! auth()->user()->can('canAccessTerminal')) {
|
||||
abort(403, 'Access to terminal functionality is restricted to team administrators');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
// if (! auth()->check()) {
|
||||
// abort(401, 'Authentication required');
|
||||
// }
|
||||
|
||||
// // Only admins/owners can access terminal functionality
|
||||
// if (! auth()->user()->can('canAccessTerminal')) {
|
||||
// abort(403, 'Access to terminal functionality is restricted to team administrators');
|
||||
// }
|
||||
|
||||
// return $next($request);
|
||||
}
|
||||
}
|
||||
+1093
-223
File diff suppressed because it is too large.
Load diff
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DEPRECATEDContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 4;
|
||||
|
||||
public function backoff(): int
|
||||
{
|
||||
return isDev() ? 1 : 3;
|
||||
}
|
||||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
GetContainersStatus::run($this->server);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Server\ResourcesCheck;
|
||||
use App\Actions\Server\ServerCheck;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DEPRECATEDServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 60;
|
||||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
ServerCheck::run($this->server);
|
||||
ResourcesCheck::dispatch($this->server);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DEPRECATEDServerResourceManager implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The time when this job execution started.
|
||||
*/
|
||||
private ?Carbon $executionTime = null;
|
||||
|
||||
private InstanceSettings $settings;
|
||||
|
||||
private string $instanceTimezone;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
(new WithoutOverlapping('server-resource-manager'))
|
||||
->releaseAfter(60),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Freeze the execution time at the start of the job
|
||||
$this->executionTime = Carbon::now();
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
|
||||
|
||||
if (validate_timezone($this->instanceTimezone) === false) {
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Process server checks - don't let failures stop the job
|
||||
try {
|
||||
$this->processServerChecks();
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to process server checks', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function processServerChecks(): void
|
||||
{
|
||||
$servers = $this->getServers();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$this->processServer($server);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing server', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getServers()
|
||||
{
|
||||
$allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||
|
||||
if (isCloud()) {
|
||||
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers;
|
||||
|
||||
return $servers->merge($own);
|
||||
} else {
|
||||
return $allServers->get();
|
||||
}
|
||||
}
|
||||
|
||||
private function processServer(Server $server): void
|
||||
{
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Sentinel check
|
||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
||||
// Dispatch ServerCheckJob if due
|
||||
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
|
||||
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
|
||||
ServerCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch ServerStorageCheckJob if due
|
||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||
}
|
||||
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
|
||||
ServerStorageCheckJob::dispatch($server);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch DockerCleanupJob if due
|
||||
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
||||
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
||||
}
|
||||
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
|
||||
DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
|
||||
}
|
||||
|
||||
// Dispatch ServerPatchCheckJob if due (weekly)
|
||||
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
|
||||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
|
||||
dispatch(function () use ($server) {
|
||||
$server->restartContainer('coolify-sentinel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||
{
|
||||
$cron = new CronExpression($frequency);
|
||||
|
||||
// Use the frozen execution time, not the current time
|
||||
$baseTime = $this->executionTime ?? Carbon::now();
|
||||
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public ?string $backup_output = null;
|
||||
|
||||
public ?string $error_output = null;
|
||||
|
||||
public bool $s3_uploaded = false;
|
||||
|
||||
public ?string $postgres_password = null;
|
||||
|
||||
public ?string $mongo_root_username = null;
|
||||
@@ -70,8 +74,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
@@ -284,6 +286,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
|
||||
}
|
||||
foreach ($databasesToBackup as $database) {
|
||||
// Generate unique UUID for each database backup execution
|
||||
$attempts = 0;
|
||||
do {
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
|
||||
$attempts++;
|
||||
if ($attempts >= 3 && $exists) {
|
||||
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
|
||||
}
|
||||
} while ($exists);
|
||||
|
||||
$size = 0;
|
||||
try {
|
||||
if (str($databaseType)->contains('postgres')) {
|
||||
@@ -355,7 +368,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// If local backup is disabled, delete the local file immediately after S3 upload
|
||||
if ($this->backup->disable_local_backup) {
|
||||
deleteBackupsLocally($this->backup_location, $this->server);
|
||||
$this->add_to_backup_output('Local backup file deleted after S3 upload (disable_local_backup enabled).');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,15 +379,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'size' => $size,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->backup_output,
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
]);
|
||||
// Check if backup actually failed or if it's just a post-backup issue
|
||||
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
|
||||
|
||||
if ($actualBackupFailed || $size === 0) {
|
||||
// Real backup failure
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
} else {
|
||||
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
|
||||
'size' => $size,
|
||||
]);
|
||||
}
|
||||
// Send success notification since the backup itself succeeded
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
// Log the post-backup issue
|
||||
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database));
|
||||
}
|
||||
}
|
||||
if ($this->backup_log && $this->backup_log->status === 'success') {
|
||||
@@ -446,7 +477,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -472,7 +503,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -492,7 +523,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -512,7 +543,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_output = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -526,6 +557,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function add_to_error_output($output): void
|
||||
{
|
||||
if ($this->error_output) {
|
||||
$this->error_output = $this->error_output."\n".$output;
|
||||
} else {
|
||||
$this->error_output = $output;
|
||||
}
|
||||
}
|
||||
|
||||
private function calculate_size()
|
||||
{
|
||||
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
|
||||
@@ -571,9 +611,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
|
||||
$this->add_to_backup_output('Uploaded to S3.');
|
||||
$this->s3_uploaded = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->add_to_backup_output($e->getMessage());
|
||||
$this->s3_uploaded = false;
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$command = "docker rm -f backup-of-{$this->backup->uuid}";
|
||||
|
||||
@@ -11,8 +11,9 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
|
||||
class PullChangelog implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
@@ -26,21 +27,36 @@ class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Fetch from CDN instead of GitHub API to avoid rate limits
|
||||
$cdnUrl = config('constants.coolify.releases_url');
|
||||
|
||||
$response = Http::retry(3, 1000)
|
||||
->timeout(30)
|
||||
->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10');
|
||||
->get($cdnUrl);
|
||||
|
||||
if ($response->successful()) {
|
||||
$releases = $response->json();
|
||||
|
||||
// Limit to 10 releases for processing (same as before)
|
||||
$releases = array_slice($releases, 0, 10);
|
||||
|
||||
$changelog = $this->transformReleasesToChangelog($releases);
|
||||
|
||||
// Group entries by month and save them
|
||||
$this->saveChangelogEntries($changelog);
|
||||
} else {
|
||||
send_internal_notification('PullChangelogFromGitHub failed with: '.$response->status().' '.$response->body());
|
||||
// Log error instead of sending notification
|
||||
Log::error('PullChangelogFromGitHub: Failed to fetch from CDN', [
|
||||
'status' => $response->status(),
|
||||
'url' => $cdnUrl,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage());
|
||||
// Log error instead of sending notification
|
||||
Log::error('PullChangelogFromGitHub: Exception occurred', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
public Collection $foundApplicationPreviewsIds;
|
||||
|
||||
public Collection $applicationContainerStatuses;
|
||||
|
||||
public bool $foundProxy = false;
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
@@ -87,6 +89,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$this->foundServiceApplicationIds = collect();
|
||||
$this->foundApplicationPreviewsIds = collect();
|
||||
$this->foundServiceDatabaseIds = collect();
|
||||
$this->applicationContainerStatuses = collect();
|
||||
$this->allApplicationIds = collect();
|
||||
$this->allDatabaseUuids = collect();
|
||||
$this->allTcpProxyUuids = collect();
|
||||
@@ -155,7 +158,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
$this->updateApplicationStatus($applicationId, $containerStatus);
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
|
||||
@@ -205,9 +215,86 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
$this->updateAdditionalServersStatus();
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
$this->aggregateMultiContainerStatuses();
|
||||
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
private function aggregateMultiContainerStatuses()
|
||||
{
|
||||
if ($this->applicationContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$aggregatedStatus = null;
|
||||
if ($hasRunning) {
|
||||
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
} else {
|
||||
// All containers are exited
|
||||
$aggregatedStatus = 'exited (unhealthy)';
|
||||
}
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||
{
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\ScheduledTaskDone;
|
||||
use App\Exceptions\NonReportableException;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
@@ -120,7 +121,7 @@ class ScheduledTaskJob implements ShouldQueue
|
||||
}
|
||||
|
||||
// No valid container was found.
|
||||
throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?');
|
||||
throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?');
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->task_log) {
|
||||
$this->task_log->update([
|
||||
|
||||
@@ -78,11 +78,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
// Server is reachable, check if Docker is available
|
||||
// $isUsable = $this->checkDockerAvailability();
|
||||
$isUsable = $this->checkDockerAvailability();
|
||||
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_usable' => $isUsable,
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -58,7 +58,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
case 'checkout.session.completed':
|
||||
$clientReferenceId = data_get($data, 'client_reference_id');
|
||||
if (is_null($clientReferenceId)) {
|
||||
send_internal_notification('Checkout session completed without client reference id.');
|
||||
// send_internal_notification('Checkout session completed without client reference id.');
|
||||
break;
|
||||
}
|
||||
$userId = Str::before($clientReferenceId, ':');
|
||||
@@ -68,7 +68,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
$team = Team::find($teamId);
|
||||
$found = $team->members->where('id', $userId)->first();
|
||||
if (! $found->isAdmin()) {
|
||||
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
@@ -93,33 +93,101 @@ class StripeProcessJob implements ShouldQueue
|
||||
break;
|
||||
case 'invoice.paid':
|
||||
$customerId = data_get($data, 'customer');
|
||||
$invoiceAmount = data_get($data, 'amount_paid', 0);
|
||||
$subscriptionId = data_get($data, 'subscription');
|
||||
$planId = data_get($data, 'lines.data.0.plan.id');
|
||||
if (Str::contains($excludedPlans, $planId)) {
|
||||
send_internal_notification('Subscription excluded.');
|
||||
// send_internal_notification('Subscription excluded.');
|
||||
break;
|
||||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
} else {
|
||||
if (! $subscription) {
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
}
|
||||
|
||||
if ($subscription->stripe_subscription_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
switch ($stripeSubscription->status) {
|
||||
case 'active':
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => true,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
send_internal_notification(
|
||||
"Invoice paid for {$stripeSubscription->status} subscription. ".
|
||||
"Customer: {$customerId}, Amount: \${$invoiceAmount}"
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
|
||||
send_internal_notification(
|
||||
'Failed to verify subscription status in invoice.paid: '.$e->getMessage()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
}
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
$customerId = data_get($data, 'customer');
|
||||
$invoiceId = data_get($data, 'id');
|
||||
$paymentIntentId = data_get($data, 'payment_intent');
|
||||
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
}
|
||||
$team = data_get($subscription, 'team');
|
||||
if (! $team) {
|
||||
send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
|
||||
// Verify payment status with Stripe API before sending failure notification
|
||||
if ($paymentIntentId) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||
|
||||
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) {
|
||||
SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60));
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
SubscriptionInvoiceFailedJob::dispatch($team);
|
||||
// send_internal_notification('Invoice payment failed: '.$customerId);
|
||||
@@ -129,11 +197,11 @@ class StripeProcessJob implements ShouldQueue
|
||||
$customerId = data_get($data, 'customer');
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if (! $subscription) {
|
||||
send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
if ($subscription->stripe_invoice_paid) {
|
||||
send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
|
||||
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +222,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
$team = Team::find($teamId);
|
||||
$found = $team->members->where('id', $userId)->first();
|
||||
if (! $found->isAdmin()) {
|
||||
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
|
||||
}
|
||||
$subscription = Subscription::where('team_id', $teamId)->first();
|
||||
@@ -177,7 +245,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
$subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id');
|
||||
$planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id');
|
||||
if (Str::contains($excludedPlans, $planId)) {
|
||||
send_internal_notification('Subscription excluded.');
|
||||
// send_internal_notification('Subscription excluded.');
|
||||
break;
|
||||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
@@ -194,7 +262,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
} else {
|
||||
send_internal_notification('No subscription and team id found');
|
||||
// send_internal_notification('No subscription and team id found');
|
||||
throw new \RuntimeException('No subscription and team id found');
|
||||
}
|
||||
}
|
||||
@@ -230,7 +298,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
$subscription->update([
|
||||
'stripe_past_due' => true,
|
||||
]);
|
||||
send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
// send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
}
|
||||
}
|
||||
if ($status === 'unpaid') {
|
||||
@@ -238,13 +306,13 @@ class StripeProcessJob implements ShouldQueue
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
// send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
|
||||
}
|
||||
$team = data_get($subscription, 'team');
|
||||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
} else {
|
||||
send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
}
|
||||
@@ -273,11 +341,11 @@ class StripeProcessJob implements ShouldQueue
|
||||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
} else {
|
||||
send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
} else {
|
||||
send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
|
||||
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
|
||||
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -23,6 +23,47 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
// Double-check subscription status before sending failure notification
|
||||
$subscription = $this->team->subscription;
|
||||
if ($subscription && $subscription->stripe_customer_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
if ($subscription->stripe_subscription_id) {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
|
||||
if (in_array($stripeSubscription->status, ['active', 'trialing'])) {
|
||||
if (! $subscription->stripe_invoice_paid) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$invoices = $stripe->invoices->all([
|
||||
'customer' => $subscription->stripe_customer_id,
|
||||
'limit' => 3,
|
||||
]);
|
||||
|
||||
foreach ($invoices->data as $invoice) {
|
||||
if ($invoice->paid && $invoice->created > (time() - 3600)) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, payment genuinely failed
|
||||
$session = getStripeCustomerPortalSession($this->team);
|
||||
$mail = new MailMessage;
|
||||
$mail->view('emails.subscription-invoice-failed', [
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [10, 30, 60];
|
||||
|
||||
public function __construct(public Subscription $subscription)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// If no subscription ID yet, try to find it via customer
|
||||
if (! $this->subscription->stripe_subscription_id &&
|
||||
$this->subscription->stripe_customer_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
'customer' => $this->subscription->stripe_customer_id,
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if ($subscriptions->data) {
|
||||
$this->subscription->update([
|
||||
'stripe_subscription_id' => $subscriptions->data[0]->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Continue without subscription ID
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->subscription->stripe_subscription_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$this->subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
switch ($stripeSubscription->status) {
|
||||
case 'active':
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
// Keep subscription active but mark as past_due
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => true,
|
||||
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
// Ensure subscription is marked as inactive
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
// Trigger subscription ended logic if canceled
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$team = $this->subscription->team;
|
||||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
send_internal_notification(
|
||||
'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status.
|
||||
' for customer: '.$this->subscription->stripe_customer_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
send_internal_notification(
|
||||
'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,63 +2,25 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public $projects = [];
|
||||
public Collection $projects;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public Collection $privateKeys;
|
||||
|
||||
public array $deploymentsPerServer = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
$this->servers = Server::ownedByCurrentTeam()->get();
|
||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
||||
$this->loadDeployments();
|
||||
}
|
||||
|
||||
public function cleanupQueue()
|
||||
{
|
||||
try {
|
||||
$this->authorize('cleanupDeploymentQueue', Application::class);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
Artisan::queue('cleanup:deployment-queue', [
|
||||
'--team-id' => currentTeam()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loadDeployments()
|
||||
{
|
||||
$this->deploymentsPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $this->servers->pluck('id'))->get([
|
||||
'id',
|
||||
'application_id',
|
||||
'application_name',
|
||||
'deployment_url',
|
||||
'pull_request_id',
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
])->sortBy('id')->groupBy('server_name')->toArray();
|
||||
}
|
||||
|
||||
public function navigateToProject($projectUuid)
|
||||
{
|
||||
return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeploymentsIndicator extends Component
|
||||
{
|
||||
public bool $expanded = false;
|
||||
|
||||
#[Computed]
|
||||
public function deployments()
|
||||
{
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
|
||||
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
|
||||
->whereIn('server_id', $servers->pluck('id'))
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'application_id',
|
||||
'application_name',
|
||||
'deployment_url',
|
||||
'pull_request_id',
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function deploymentCount()
|
||||
{
|
||||
return $this->deployments->count();
|
||||
}
|
||||
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = ! $this->expanded;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.deployments-indicator');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class GlobalSearch extends Component
|
||||
{
|
||||
public $searchQuery = '';
|
||||
|
||||
public $isModalOpen = false;
|
||||
|
||||
public $searchResults = [];
|
||||
|
||||
public $allSearchableItems = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->searchQuery = '';
|
||||
$this->isModalOpen = false;
|
||||
$this->searchResults = [];
|
||||
$this->allSearchableItems = [];
|
||||
}
|
||||
|
||||
public function openSearchModal()
|
||||
{
|
||||
$this->isModalOpen = true;
|
||||
$this->loadSearchableItems();
|
||||
$this->dispatch('search-modal-opened');
|
||||
}
|
||||
|
||||
public function closeSearchModal()
|
||||
{
|
||||
$this->isModalOpen = false;
|
||||
$this->searchQuery = '';
|
||||
$this->searchResults = [];
|
||||
}
|
||||
|
||||
public static function getCacheKey($teamId)
|
||||
{
|
||||
return 'global_search_items_'.$teamId;
|
||||
}
|
||||
|
||||
public static function clearTeamCache($teamId)
|
||||
{
|
||||
Cache::forget(self::getCacheKey($teamId));
|
||||
}
|
||||
|
||||
public function updatedSearchQuery()
|
||||
{
|
||||
$this->search();
|
||||
}
|
||||
|
||||
private function loadSearchableItems()
|
||||
{
|
||||
// Try to get from Redis cache first
|
||||
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
|
||||
|
||||
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
|
||||
ray()->showQueries();
|
||||
$items = collect();
|
||||
$team = auth()->user()->currentTeam();
|
||||
|
||||
// Get all applications
|
||||
$applications = Application::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($app) {
|
||||
// Collect all FQDNs from the application
|
||||
$fqdns = collect([]);
|
||||
|
||||
// For regular applications
|
||||
if ($app->fqdn) {
|
||||
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
|
||||
}
|
||||
|
||||
// For docker compose based applications
|
||||
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
|
||||
try {
|
||||
$composeDomains = json_decode($app->docker_compose_domains, true);
|
||||
if (is_array($composeDomains)) {
|
||||
foreach ($composeDomains as $serviceName => $domains) {
|
||||
if (is_array($domains)) {
|
||||
$fqdns = $fqdns->merge($domains);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore JSON parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
$fqdnsString = $fqdns->implode(' ');
|
||||
|
||||
return [
|
||||
'id' => $app->id,
|
||||
'name' => $app->name,
|
||||
'type' => 'application',
|
||||
'uuid' => $app->uuid,
|
||||
'description' => $app->description,
|
||||
'link' => $app->link(),
|
||||
'project' => $app->environment->project->name ?? null,
|
||||
'environment' => $app->environment->name ?? null,
|
||||
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
|
||||
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all services
|
||||
$services = Service::ownedByCurrentTeam()
|
||||
->with(['environment.project', 'applications'])
|
||||
->get()
|
||||
->map(function ($service) {
|
||||
// Collect all FQDNs from service applications
|
||||
$fqdns = collect([]);
|
||||
foreach ($service->applications as $app) {
|
||||
if ($app->fqdn) {
|
||||
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
|
||||
$fqdns = $fqdns->merge($appFqdns);
|
||||
}
|
||||
}
|
||||
$fqdnsString = $fqdns->implode(' ');
|
||||
|
||||
return [
|
||||
'id' => $service->id,
|
||||
'name' => $service->name,
|
||||
'type' => 'service',
|
||||
'uuid' => $service->uuid,
|
||||
'description' => $service->description,
|
||||
'link' => $service->link(),
|
||||
'project' => $service->environment->project->name ?? null,
|
||||
'environment' => $service->environment->name ?? null,
|
||||
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
|
||||
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all standalone databases
|
||||
$databases = collect();
|
||||
|
||||
// PostgreSQL
|
||||
$databases = $databases->merge(
|
||||
StandalonePostgresql::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'postgresql',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' postgresql '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MySQL
|
||||
$databases = $databases->merge(
|
||||
StandaloneMysql::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mysql',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mysql '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MariaDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneMariadb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mariadb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mariadb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MongoDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneMongodb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mongodb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mongodb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Redis
|
||||
$databases = $databases->merge(
|
||||
StandaloneRedis::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'redis',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' redis '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// KeyDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneKeydb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'keydb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' keydb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Dragonfly
|
||||
$databases = $databases->merge(
|
||||
StandaloneDragonfly::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'dragonfly',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' dragonfly '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Clickhouse
|
||||
$databases = $databases->merge(
|
||||
StandaloneClickhouse::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'clickhouse',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' clickhouse '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Get all servers
|
||||
$servers = Server::ownedByCurrentTeam()
|
||||
->get()
|
||||
->map(function ($server) {
|
||||
return [
|
||||
'id' => $server->id,
|
||||
'name' => $server->name,
|
||||
'type' => 'server',
|
||||
'uuid' => $server->uuid,
|
||||
'description' => $server->description,
|
||||
'link' => $server->url(),
|
||||
'project' => null,
|
||||
'environment' => null,
|
||||
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all projects
|
||||
$projects = Project::ownedByCurrentTeam()
|
||||
->withCount(['environments', 'applications', 'services'])
|
||||
->get()
|
||||
->map(function ($project) {
|
||||
$resourceCount = $project->applications_count + $project->services_count;
|
||||
$resourceSummary = $resourceCount > 0
|
||||
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
|
||||
: 'No resources';
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'type' => 'project',
|
||||
'uuid' => $project->uuid,
|
||||
'description' => $project->description,
|
||||
'link' => $project->navigateTo(),
|
||||
'project' => null,
|
||||
'environment' => null,
|
||||
'resource_count' => $resourceSummary,
|
||||
'environment_count' => $project->environments_count,
|
||||
'search_text' => strtolower($project->name.' '.$project->description.' project'),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all environments
|
||||
$environments = Environment::query()
|
||||
->whereHas('project', function ($query) {
|
||||
$query->where('team_id', auth()->user()->currentTeam()->id);
|
||||
})
|
||||
->with('project')
|
||||
->withCount(['applications', 'services'])
|
||||
->get()
|
||||
->map(function ($environment) {
|
||||
$resourceCount = $environment->applications_count + $environment->services_count;
|
||||
$resourceSummary = $resourceCount > 0
|
||||
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
|
||||
: 'No resources';
|
||||
|
||||
// Build description with project context
|
||||
$descriptionParts = [];
|
||||
if ($environment->project) {
|
||||
$descriptionParts[] = "Project: {$environment->project->name}";
|
||||
}
|
||||
if ($environment->description) {
|
||||
$descriptionParts[] = $environment->description;
|
||||
}
|
||||
if (empty($descriptionParts)) {
|
||||
$descriptionParts[] = $resourceSummary;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $environment->id,
|
||||
'name' => $environment->name,
|
||||
'type' => 'environment',
|
||||
'uuid' => $environment->uuid,
|
||||
'description' => implode(' • ', $descriptionParts),
|
||||
'link' => route('project.resource.index', [
|
||||
'project_uuid' => $environment->project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
]),
|
||||
'project' => $environment->project->name ?? null,
|
||||
'environment' => null,
|
||||
'resource_count' => $resourceSummary,
|
||||
'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'),
|
||||
];
|
||||
});
|
||||
|
||||
// Merge all collections
|
||||
$items = $items->merge($applications)
|
||||
->merge($services)
|
||||
->merge($databases)
|
||||
->merge($servers)
|
||||
->merge($projects)
|
||||
->merge($environments);
|
||||
|
||||
return $items->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
private function search()
|
||||
{
|
||||
if (strlen($this->searchQuery) < 2) {
|
||||
$this->searchResults = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query = strtolower($this->searchQuery);
|
||||
|
||||
// Case-insensitive search in the items
|
||||
$this->searchResults = collect($this->allSearchableItems)
|
||||
->filter(function ($item) use ($query) {
|
||||
return str_contains($item['search_text'], $query);
|
||||
})
|
||||
->take(20)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.global-search');
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class Help extends Component
|
||||
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
|
||||
]);
|
||||
} else {
|
||||
send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io');
|
||||
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
|
||||
}
|
||||
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
|
||||
$this->reset('description', 'subject');
|
||||
|
||||
@@ -78,6 +78,8 @@ class Index extends Component
|
||||
'new_email' => ['required', 'email', 'unique:users,email'],
|
||||
]);
|
||||
|
||||
$this->new_email = strtolower($this->new_email);
|
||||
|
||||
// Skip rate limiting in development mode
|
||||
if (! isDev()) {
|
||||
// Rate limit by current user's email (1 request per 2 minutes)
|
||||
@@ -90,7 +92,7 @@ class Index extends Component
|
||||
}
|
||||
|
||||
// Rate limit by new email address (3 requests per hour per email)
|
||||
$newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email));
|
||||
$newEmailKey = 'email-change:email:'.md5($this->new_email);
|
||||
if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
|
||||
$this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class Advanced extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isPreviewDeploymentsEnabled = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isPrDeploymentsPublicEnabled = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isAutoDeployEnabled = true;
|
||||
|
||||
@@ -91,6 +94,7 @@ class Advanced extends Component
|
||||
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
|
||||
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
|
||||
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
|
||||
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
|
||||
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
|
||||
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->application->settings->is_gpu_enabled = $this->isGpuEnabled;
|
||||
@@ -117,6 +121,7 @@ class Advanced extends Component
|
||||
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
|
||||
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
|
||||
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
|
||||
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
|
||||
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
|
||||
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
|
||||
$this->gpuDriver = $this->application->settings->gpu_driver;
|
||||
|
||||
@@ -50,17 +50,48 @@ class DeploymentNavbar extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function copyLogsToClipboard(): string
|
||||
{
|
||||
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! $logs) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$markdown = "# Deployment Logs\n\n";
|
||||
$markdown .= "```\n";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if (isset($log['output'])) {
|
||||
$markdown .= $log['output']."\n";
|
||||
}
|
||||
}
|
||||
|
||||
$markdown .= "```\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}";
|
||||
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
|
||||
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
|
||||
|
||||
// First, mark the deployment as cancelled to prevent further processing
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($this->application->settings->is_build_server_enabled) {
|
||||
$server = Server::ownedByCurrentTeam()->find($build_server_id);
|
||||
} else {
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
}
|
||||
|
||||
// Add cancellation log entry
|
||||
if ($this->application_deployment_queue->logs) {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -77,13 +108,35 @@ class DeploymentNavbar extends Component
|
||||
'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
instant_remote_process([$kill_command], $server);
|
||||
|
||||
// Try to stop the helper container if it exists
|
||||
// Check if container exists first
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
// Container exists, kill it
|
||||
instant_remote_process([$kill_command], $server);
|
||||
} else {
|
||||
// Container hasn't started yet
|
||||
$this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Also try to kill any running process if we have a process ID
|
||||
if ($this->application_deployment_queue->current_process_id) {
|
||||
try {
|
||||
$processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
} catch (\Throwable $e) {
|
||||
// Process might already be gone, that's ok
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Still mark as cancelled even if cleanup fails
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'current_process_id' => null,
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
next_after_cancel($server);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,16 @@ class General extends Component
|
||||
|
||||
public $parsedServiceDomains = [];
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
protected $listeners = [
|
||||
'resetDefaultLabels',
|
||||
'configurationChanged' => '$refresh',
|
||||
'confirmDomainUsage',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
@@ -203,10 +210,10 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||
// Convert service names with dots to use underscores for HTML form binding
|
||||
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||
$sanitizedDomains = [];
|
||||
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
||||
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
||||
$sanitizedDomains[$sanitizedKey] = $domain;
|
||||
}
|
||||
$this->parsedServiceDomains = $sanitizedDomains;
|
||||
@@ -298,10 +305,10 @@ class General extends Component
|
||||
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
|
||||
$this->application->refresh();
|
||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||
// Convert service names with dots to use underscores for HTML form binding
|
||||
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||
$sanitizedDomains = [];
|
||||
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
||||
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
||||
$sanitizedDomains[$sanitizedKey] = $domain;
|
||||
}
|
||||
$this->parsedServiceDomains = $sanitizedDomains;
|
||||
@@ -327,7 +334,7 @@ class General extends Component
|
||||
|
||||
$uuid = new Cuid2;
|
||||
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
|
||||
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
||||
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
|
||||
|
||||
// Convert back to original service names for storage
|
||||
@@ -337,7 +344,7 @@ class General extends Component
|
||||
$originalServiceName = $key;
|
||||
if (isset($this->parsedServices['services'])) {
|
||||
foreach ($this->parsedServices['services'] as $originalName => $service) {
|
||||
if (str($originalName)->slug('_')->toString() === $key) {
|
||||
if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
|
||||
$originalServiceName = $originalName;
|
||||
break;
|
||||
}
|
||||
@@ -430,7 +437,7 @@ class General extends Component
|
||||
|
||||
$server = data_get($this->application, 'destination.server');
|
||||
if ($server) {
|
||||
$fqdn = generateFqdn(server: $server, random: $this->application->uuid, parserVersion: $this->application->compose_parsing_version);
|
||||
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
|
||||
$this->application->fqdn = $fqdn;
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
@@ -480,15 +487,38 @@ class General extends Component
|
||||
$domains = str($this->application->fqdn)->trim()->explode(',');
|
||||
if ($this->application->additional_servers->count() === 0) {
|
||||
foreach ($domains as $domain) {
|
||||
if (! validate_dns_entry($domain, $this->application->destination->server)) {
|
||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
||||
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
}
|
||||
}
|
||||
}
|
||||
check_domain_usage(resource: $this->application);
|
||||
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
$this->application->fqdn = $domains->implode(',');
|
||||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function setRedirect()
|
||||
@@ -517,9 +547,10 @@ class General extends Component
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
@@ -536,7 +567,9 @@ class General extends Component
|
||||
$this->application->parseHealthcheckFromDockerfile($this->application->dockerfile);
|
||||
}
|
||||
|
||||
$this->checkFqdns();
|
||||
if (! $this->checkFqdns()) {
|
||||
return; // Stop if there are conflicts and user hasn't confirmed
|
||||
}
|
||||
|
||||
$this->application->save();
|
||||
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) {
|
||||
@@ -583,12 +616,25 @@ class General extends Component
|
||||
foreach ($this->parsedServiceDomains as $service) {
|
||||
$domain = data_get($service, 'domain');
|
||||
if ($domain) {
|
||||
if (! validate_dns_entry($domain, $this->application->destination->server)) {
|
||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
||||
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
}
|
||||
}
|
||||
}
|
||||
check_domain_usage(resource: $this->application);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
}
|
||||
@@ -626,7 +672,7 @@ class General extends Component
|
||||
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
|
||||
|
||||
foreach ($domains as $serviceName => $service) {
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
|
||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$domain = data_get($service, 'domain');
|
||||
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
|
||||
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
||||
@@ -658,7 +704,6 @@ class General extends Component
|
||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -667,7 +712,6 @@ class General extends Component
|
||||
'key' => "SERVICE_URL_{$serviceNameFormatted}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Create/update port-specific variables if port exists
|
||||
@@ -676,7 +720,6 @@ class General extends Component
|
||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -684,7 +727,6 @@ class General extends Component
|
||||
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ class Previews extends Component
|
||||
|
||||
public int $rate_limit_remaining;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public $pendingPreviewId = null;
|
||||
|
||||
protected $rules = [
|
||||
'application.previews.*.fqdn' => 'string|nullable',
|
||||
];
|
||||
@@ -49,6 +57,16 @@ class Previews extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
if ($this->pendingPreviewId) {
|
||||
$this->save_preview($this->pendingPreviewId);
|
||||
$this->pendingPreviewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function save_preview($preview_id)
|
||||
{
|
||||
try {
|
||||
@@ -59,11 +77,24 @@ class Previews extends Component
|
||||
$preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim();
|
||||
$preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim();
|
||||
$preview->fqdn = str($preview->fqdn)->trim()->lower();
|
||||
if (! validate_dns_entry($preview->fqdn, $this->application->destination->server)) {
|
||||
if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) {
|
||||
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$preview->fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
$success = false;
|
||||
}
|
||||
check_domain_usage(resource: $this->application, domain: $preview->fqdn);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
$this->pendingPreviewId = $preview_id;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $preview) {
|
||||
@@ -200,6 +231,18 @@ class Previews extends Component
|
||||
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
|
||||
}
|
||||
|
||||
private function stopContainers(array $containers, $server)
|
||||
{
|
||||
$containersToStop = collect($containers)->pluck('Names')->toArray();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
}
|
||||
|
||||
public function stop(int $pull_request_id)
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
@@ -60,7 +60,7 @@ class PreviewsCompose extends Component
|
||||
$random = new Cuid2;
|
||||
|
||||
// Generate a unique domain like main app services do
|
||||
$generated_fqdn = generateFqdn(server: $server, random: $random, parserVersion: $this->preview->application->compose_parsing_version);
|
||||
$generated_fqdn = generateUrl(server: $server, random: $random);
|
||||
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
|
||||
@@ -72,10 +72,13 @@ class PreviewsCompose extends Component
|
||||
$template = $this->preview->application->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$random = new Cuid2;
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
|
||||
$preview_fqdn = "$schema://$preview_fqdn";
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,21 @@ class Source extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedGitRepository()
|
||||
{
|
||||
$this->gitRepository = trim($this->gitRepository);
|
||||
}
|
||||
|
||||
public function updatedGitBranch()
|
||||
{
|
||||
$this->gitBranch = trim($this->gitBranch);
|
||||
}
|
||||
|
||||
public function updatedGitCommitSha()
|
||||
{
|
||||
$this->gitCommitSha = trim($this->gitCommitSha);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
@@ -57,6 +72,9 @@ class Source extends Component
|
||||
'git_commit_sha' => $this->gitCommitSha,
|
||||
'private_key_id' => $this->privateKeyId,
|
||||
]);
|
||||
// Refresh to get the trimmed values from the model
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
} else {
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->gitBranch = $this->application->git_branch;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Project;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
@@ -128,144 +127,10 @@ class CloneMe extends Component
|
||||
$databases = $this->environment->databases();
|
||||
$services = $this->environment->services;
|
||||
foreach ($applications as $application) {
|
||||
$applicationSettings = $application->settings;
|
||||
|
||||
$uuid = (string) new Cuid2;
|
||||
$url = $application->fqdn;
|
||||
if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$url = generateFqdn(server: $this->server, random: $uuid, parserVersion: $application->compose_parsing_version);
|
||||
}
|
||||
|
||||
$newApplication = $application->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'additional_servers_count',
|
||||
'additional_networks_count',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'fqdn' => $url,
|
||||
'status' => 'exited',
|
||||
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
|
||||
clone_application($application, $selectedDestination, [
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $this->selectedDestination,
|
||||
]);
|
||||
$newApplication->save();
|
||||
|
||||
if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
|
||||
$newApplication->custom_labels = base64_encode($customLabels);
|
||||
$newApplication->save();
|
||||
}
|
||||
|
||||
$newApplication->settings()->delete();
|
||||
if ($applicationSettings) {
|
||||
$newApplicationSettings = $applicationSettings->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $newApplication->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
}
|
||||
|
||||
$tags = $application->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newApplication->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $application->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$applicationPreviews = $application->previews()->get();
|
||||
foreach ($applicationPreviews as $preview) {
|
||||
$newPreview = $preview->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $newApplication->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$newPreview->save();
|
||||
}
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $application->uuid)) {
|
||||
$newName = str($volume->name)->replace($application->uuid, $newApplication->uuid);
|
||||
} else {
|
||||
$newName = $newApplication->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopApplication::dispatch($application, false, false);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $application->destination->server;
|
||||
$targetServer = $newApplication->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
application: $application,
|
||||
server: $sourceServer,
|
||||
destination: $application->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $application->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $newApplication->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$environmentVaribles = $application->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newApplication->id,
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
], $this->cloneVolumeData);
|
||||
}
|
||||
|
||||
foreach ($databases as $database) {
|
||||
|
||||
@@ -63,7 +63,6 @@ class DockerCompose extends Component
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $variable,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
|
||||
@@ -64,7 +64,7 @@ class DockerImage extends Component
|
||||
'health_check_enabled' => false,
|
||||
]);
|
||||
|
||||
$fqdn = generateFqdn($destination->server, $application->uuid);
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->update([
|
||||
'name' => 'docker-image-'.$application->uuid,
|
||||
'fqdn' => $fqdn,
|
||||
|
||||
@@ -143,7 +143,13 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
protected function loadBranchByPage()
|
||||
{
|
||||
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}");
|
||||
$response = Http::GitHub($this->github_app->api_url, $this->token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
|
||||
'per_page' => 100,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
$json = $response->json();
|
||||
if ($response->status() !== 200) {
|
||||
return $this->dispatch('error', $json['message']);
|
||||
@@ -208,7 +214,7 @@ class GithubPrivateRepository extends Component
|
||||
$application['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application['base_directory'] = $this->base_directory;
|
||||
}
|
||||
$fqdn = generateFqdn($destination->server, $application->uuid);
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
|
||||
$application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid);
|
||||
|
||||
@@ -90,7 +90,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples';
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
@@ -194,7 +194,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
$application->settings->is_static = $this->is_static;
|
||||
$application->settings->save();
|
||||
|
||||
$fqdn = generateFqdn($destination->server, $application->uuid);
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
$application->name = generate_random_name($application->uuid);
|
||||
$application->save();
|
||||
|
||||
@@ -100,7 +100,7 @@ class PublicGitRepository extends Component
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples';
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x';
|
||||
$this->port = 3000;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
@@ -176,13 +176,16 @@ class PublicGitRepository extends Component
|
||||
str($this->repository_url)->startsWith('http://')) &&
|
||||
! str($this->repository_url)->endsWith('.git') &&
|
||||
(! str($this->repository_url)->contains('github.com') ||
|
||||
! str($this->repository_url)->contains('git.sr.ht'))
|
||||
! str($this->repository_url)->contains('git.sr.ht')) &&
|
||||
! str($this->repository_url)->contains('tangled')
|
||||
) {
|
||||
|
||||
$this->repository_url = $this->repository_url.'.git';
|
||||
}
|
||||
if (str($this->repository_url)->contains('github.com') && str($this->repository_url)->endsWith('.git')) {
|
||||
$this->repository_url = str($this->repository_url)->beforeLast('.git')->value();
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -190,6 +193,9 @@ class PublicGitRepository extends Component
|
||||
$this->branchFound = false;
|
||||
$this->getGitSource();
|
||||
$this->getBranch();
|
||||
if (str($this->repository_url)->contains('tangled')) {
|
||||
$this->git_branch = 'master';
|
||||
}
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->rate_limit_remaining == 0) {
|
||||
@@ -373,7 +379,7 @@ class PublicGitRepository extends Component
|
||||
|
||||
$application->settings->is_static = $this->isStatic;
|
||||
$application->settings->save();
|
||||
$fqdn = generateFqdn($destination->server, $application->uuid);
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
if ($this->checkCoolifyConfig) {
|
||||
|
||||
@@ -68,7 +68,7 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
$fqdn = generateFqdn($destination->server, $application->uuid);
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->update([
|
||||
'name' => 'dockerfile-'.$application->uuid,
|
||||
'fqdn' => $fqdn,
|
||||
|
||||
@@ -97,7 +97,6 @@ class Create extends Component
|
||||
'value' => $value,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ class EditDomain extends Component
|
||||
|
||||
public ServiceApplication $application;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
protected $rules = [
|
||||
'application.fqdn' => 'nullable',
|
||||
'application.required_fqdn' => 'required|boolean',
|
||||
@@ -22,22 +28,43 @@ class EditDomain extends Component
|
||||
$this->application = ServiceApplication::find($this->applicationId);
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
$warning = sslipDomainWarning($this->application->fqdn);
|
||||
if ($warning) {
|
||||
$this->dispatch('warning', __('warning.sslipdomain'));
|
||||
}
|
||||
check_domain_usage(resource: $this->application);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
$this->application->save();
|
||||
updateCompose($this->application);
|
||||
|
||||
@@ -23,6 +23,12 @@ class ServiceApplicationView extends Component
|
||||
|
||||
public $delete_volumes = true;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
protected $rules = [
|
||||
'application.human_name' => 'nullable',
|
||||
'application.description' => 'nullable',
|
||||
@@ -129,6 +135,13 @@ class ServiceApplicationView extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -136,16 +149,30 @@ class ServiceApplicationView extends Component
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
$warning = sslipDomainWarning($this->application->fqdn);
|
||||
if ($warning) {
|
||||
$this->dispatch('warning', __('warning.sslipdomain'));
|
||||
}
|
||||
check_domain_usage(resource: $this->application);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
$this->application->save();
|
||||
updateCompose($this->application);
|
||||
|
||||
@@ -14,6 +14,22 @@ class Storage extends Component
|
||||
|
||||
public $fileStorage;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $mount_path = '';
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path = '';
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source = '';
|
||||
|
||||
public string $file_storage_directory_destination = '';
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
@@ -27,6 +43,18 @@ class Storage extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->refreshStorages();
|
||||
}
|
||||
|
||||
@@ -39,30 +67,151 @@ class Storage extends Component
|
||||
public function refreshStorages()
|
||||
{
|
||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||
$this->dispatch('$refresh');
|
||||
$this->resource->refresh();
|
||||
}
|
||||
|
||||
public function addNewVolume($data)
|
||||
public function getFilesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', false);
|
||||
}
|
||||
|
||||
public function getDirectoriesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', true);
|
||||
}
|
||||
|
||||
public function getVolumeCountProperty()
|
||||
{
|
||||
return $this->resource->persistentStorages()->count();
|
||||
}
|
||||
|
||||
public function getFileCountProperty()
|
||||
{
|
||||
return $this->files->count();
|
||||
}
|
||||
|
||||
public function getDirectoryCountProperty()
|
||||
{
|
||||
return $this->directories->count();
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
|
||||
]);
|
||||
|
||||
$name = $this->resource->uuid.'-'.$this->name;
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $data['name'],
|
||||
'mount_path' => $data['mount_path'],
|
||||
'host_path' => $data['host_path'],
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => $this->resource->getMorphClass(),
|
||||
]);
|
||||
$this->resource->refresh();
|
||||
$this->dispatch('success', 'Storage added successfully');
|
||||
$this->dispatch('clearAddStorage');
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('success', 'Volume added successfully');
|
||||
$this->dispatch('closeStorageModal', 'volume');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'required|string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'File mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'file');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'required|string',
|
||||
'file_storage_directory_destination' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Directory mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'directory');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
$this->file_storage_path = '';
|
||||
$this->file_storage_content = null;
|
||||
$this->file_storage_directory_destination = '';
|
||||
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.storage');
|
||||
|
||||
@@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
protected $listeners = ['configurationChanged'];
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
|
||||
'configurationChanged' => 'configurationChanged',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer;
|
||||
|
||||
public $parameters;
|
||||
|
||||
@@ -19,33 +20,40 @@ class Add extends Component
|
||||
|
||||
public ?string $value = null;
|
||||
|
||||
public bool $is_build_time = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
|
||||
public bool $is_literal = false;
|
||||
|
||||
public bool $is_runtime = true;
|
||||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'is_build_time' => 'required|boolean',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'key' => 'key',
|
||||
'value' => 'value',
|
||||
'is_build_time' => 'build',
|
||||
'is_multiline' => 'multiline',
|
||||
'is_literal' => 'literal',
|
||||
'is_runtime' => 'runtime',
|
||||
'is_buildtime' => 'buildtime',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -54,9 +62,10 @@ class Add extends Component
|
||||
$this->dispatch('saveKey', [
|
||||
'key' => $this->key,
|
||||
'value' => $this->value,
|
||||
'is_build_time' => $this->is_build_time,
|
||||
'is_multiline' => $this->is_multiline,
|
||||
'is_literal' => $this->is_literal,
|
||||
'is_runtime' => $this->is_runtime,
|
||||
'is_buildtime' => $this->is_buildtime,
|
||||
'is_preview' => $this->is_preview,
|
||||
]);
|
||||
$this->clear();
|
||||
@@ -66,8 +75,9 @@ class Add extends Component
|
||||
{
|
||||
$this->key = '';
|
||||
$this->value = '';
|
||||
$this->is_build_time = false;
|
||||
$this->is_multiline = false;
|
||||
$this->is_literal = false;
|
||||
$this->is_runtime = true;
|
||||
$this->is_buildtime = true;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ class All extends Component
|
||||
|
||||
public bool $is_env_sorting_enabled = false;
|
||||
|
||||
public bool $use_build_secrets = false;
|
||||
|
||||
protected $listeners = [
|
||||
'saveKey' => 'submit',
|
||||
'refreshEnvs',
|
||||
@@ -34,13 +36,14 @@ class All extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
|
||||
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
|
||||
$this->resourceClass = get_class($this->resource);
|
||||
$resourceWithPreviews = [\App\Models\Application::class];
|
||||
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
|
||||
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
||||
$this->showPreview = true;
|
||||
}
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
@@ -49,34 +52,38 @@ class All extends Component
|
||||
$this->authorize('manageEnvironment', $this->resource);
|
||||
|
||||
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
|
||||
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
|
||||
$this->resource->settings->save();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
$this->dispatch('success', 'Environment variable settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function sortEnvironmentVariables()
|
||||
public function getEnvironmentVariablesProperty()
|
||||
{
|
||||
if ($this->is_env_sorting_enabled === false) {
|
||||
if ($this->resource->environment_variables) {
|
||||
$this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values();
|
||||
}
|
||||
|
||||
if ($this->resource->environment_variables_preview) {
|
||||
$this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values();
|
||||
}
|
||||
return $this->resource->environment_variables()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
$this->getDevView();
|
||||
return $this->resource->environment_variables;
|
||||
}
|
||||
|
||||
public function getEnvironmentVariablesPreviewProperty()
|
||||
{
|
||||
if ($this->is_env_sorting_enabled === false) {
|
||||
return $this->resource->environment_variables_preview()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
return $this->resource->environment_variables_preview;
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables);
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
||||
if ($this->showPreview) {
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview);
|
||||
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +104,7 @@ class All extends Component
|
||||
public function switch()
|
||||
{
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function submit($data = null)
|
||||
@@ -111,7 +118,7 @@ class All extends Component
|
||||
}
|
||||
|
||||
$this->updateOrder();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -212,9 +219,10 @@ class All extends Component
|
||||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $data['key'];
|
||||
$environment->value = $data['value'];
|
||||
$environment->is_build_time = $data['is_build_time'] ?? false;
|
||||
$environment->is_multiline = $data['is_multiline'] ?? false;
|
||||
$environment->is_literal = $data['is_literal'] ?? false;
|
||||
$environment->is_runtime = $data['is_runtime'] ?? true;
|
||||
$environment->is_buildtime = $data['is_buildtime'] ?? true;
|
||||
$environment->is_preview = $data['is_preview'] ?? false;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
$environment->resourceable_type = $this->resource->getMorphClass();
|
||||
@@ -257,7 +265,7 @@ class All extends Component
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
|
||||
continue;
|
||||
}
|
||||
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
||||
@@ -276,7 +284,6 @@ class All extends Component
|
||||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $key;
|
||||
$environment->value = $value;
|
||||
$environment->is_build_time = false;
|
||||
$environment->is_multiline = false;
|
||||
$environment->is_preview = $isPreview;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
@@ -293,7 +300,6 @@ class All extends Component
|
||||
public function refreshEnvs()
|
||||
{
|
||||
$this->resource->refresh();
|
||||
$this->sortEnvironmentVariables();
|
||||
$this->getDevView();
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests, EnvironmentVariableProtection;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
|
||||
|
||||
public $parameters;
|
||||
|
||||
@@ -32,20 +33,24 @@ class Show extends Component
|
||||
|
||||
public bool $is_shared = false;
|
||||
|
||||
public bool $is_build_time = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
|
||||
public bool $is_literal = false;
|
||||
|
||||
public bool $is_shown_once = false;
|
||||
|
||||
public bool $is_runtime = true;
|
||||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public bool $is_required = false;
|
||||
|
||||
public bool $is_really_required = false;
|
||||
|
||||
public bool $is_redis_credential = false;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = [
|
||||
'refreshEnvs' => 'refresh',
|
||||
'refresh',
|
||||
@@ -55,10 +60,11 @@ class Show extends Component
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'is_build_time' => 'required|boolean',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'real_value' => 'nullable',
|
||||
'is_required' => 'required|boolean',
|
||||
];
|
||||
@@ -74,6 +80,7 @@ class Show extends Component
|
||||
if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) {
|
||||
$this->is_redis_credential = true;
|
||||
}
|
||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
public function getResourceProperty()
|
||||
@@ -101,8 +108,9 @@ class Show extends Component
|
||||
]);
|
||||
} else {
|
||||
$this->validate();
|
||||
$this->env->is_build_time = $this->is_build_time;
|
||||
$this->env->is_required = $this->is_required;
|
||||
$this->env->is_runtime = $this->is_runtime;
|
||||
$this->env->is_buildtime = $this->is_buildtime;
|
||||
$this->env->is_shared = $this->is_shared;
|
||||
}
|
||||
$this->env->key = $this->key;
|
||||
@@ -114,10 +122,11 @@ class Show extends Component
|
||||
} else {
|
||||
$this->key = $this->env->key;
|
||||
$this->value = $this->env->value;
|
||||
$this->is_build_time = $this->env->is_build_time ?? false;
|
||||
$this->is_multiline = $this->env->is_multiline;
|
||||
$this->is_literal = $this->env->is_literal;
|
||||
$this->is_shown_once = $this->env->is_shown_once;
|
||||
$this->is_runtime = $this->env->is_runtime ?? true;
|
||||
$this->is_buildtime = $this->env->is_buildtime ?? true;
|
||||
$this->is_required = $this->env->is_required ?? false;
|
||||
$this->is_really_required = $this->env->is_really_required ?? false;
|
||||
$this->is_shared = $this->env->is_shared ?? false;
|
||||
@@ -128,7 +137,7 @@ class Show extends Component
|
||||
public function checkEnvs()
|
||||
{
|
||||
$this->isDisabled = false;
|
||||
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) {
|
||||
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) {
|
||||
$this->isDisabled = true;
|
||||
}
|
||||
if ($this->env->is_shown_once) {
|
||||
@@ -139,9 +148,6 @@ class Show extends Component
|
||||
public function serialize()
|
||||
{
|
||||
data_forget($this->env, 'real_value');
|
||||
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
|
||||
data_forget($this->env, 'is_build_time');
|
||||
}
|
||||
}
|
||||
|
||||
public function lock()
|
||||
|
||||
@@ -33,9 +33,6 @@ class ExecuteContainerCommand extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->containers = collect();
|
||||
$this->servers = collect();
|
||||
|
||||
@@ -47,6 +47,24 @@ class HealthChecks extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleHealthcheck()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$wasEnabled = $this->resource->health_check_enabled;
|
||||
$this->resource->health_check_enabled = ! $this->resource->health_check_enabled;
|
||||
$this->resource->save();
|
||||
|
||||
if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) {
|
||||
$this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.');
|
||||
} else {
|
||||
$this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.health-checks');
|
||||
|
||||
@@ -8,7 +8,7 @@ class Metrics extends Component
|
||||
{
|
||||
public $resource;
|
||||
|
||||
public $chartId = 'container-cpu';
|
||||
public $chartId = 'metrics';
|
||||
|
||||
public $data;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
@@ -61,145 +60,7 @@ class ResourceOperations extends Component
|
||||
$server = $new_destination->server;
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$name = 'clone-of-'.str($this->resource->name)->limit(20).'-'.$uuid;
|
||||
$applicationSettings = $this->resource->settings;
|
||||
$url = $this->resource->fqdn;
|
||||
|
||||
if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$url = generateFqdn(server: $server, random: $uuid, parserVersion: $this->resource->compose_parsing_version);
|
||||
}
|
||||
|
||||
$new_resource = $this->resource->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'additional_servers_count',
|
||||
'additional_networks_count',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'name' => $name,
|
||||
'fqdn' => $url,
|
||||
'status' => 'exited',
|
||||
'destination_id' => $new_destination->id,
|
||||
]);
|
||||
$new_resource->save();
|
||||
|
||||
if ($new_resource->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($new_resource)))->replace('|coolify|', "\n");
|
||||
$new_resource->custom_labels = base64_encode($customLabels);
|
||||
$new_resource->save();
|
||||
}
|
||||
|
||||
$new_resource->settings()->delete();
|
||||
if ($applicationSettings) {
|
||||
$newApplicationSettings = $applicationSettings->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $new_resource->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
}
|
||||
|
||||
$tags = $this->resource->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$new_resource->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $this->resource->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $new_resource->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$applicationPreviews = $this->resource->previews()->get();
|
||||
foreach ($applicationPreviews as $preview) {
|
||||
$newPreview = $preview->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'application_id' => $new_resource->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$newPreview->save();
|
||||
}
|
||||
|
||||
$persistentVolumes = $this->resource->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $this->resource->uuid)) {
|
||||
$newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid);
|
||||
} else {
|
||||
$newName = $new_resource->uuid.'-'.str($volume->name)->afterLast('-');
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $new_resource->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopApplication::dispatch($this->resource, false, false);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $this->resource->destination->server;
|
||||
$targetServer = $new_resource->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
application: $this->resource,
|
||||
server: $sourceServer,
|
||||
destination: $this->resource->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $this->resource->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $new_resource->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$environmentVaribles = $this->resource->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $new_resource->id,
|
||||
'resourceable_type' => $new_resource->getMorphClass(),
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
|
||||
|
||||
$route = route('project.application.configuration', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
|
||||
@@ -105,6 +105,19 @@ class Executions extends Component
|
||||
$this->currentPage++;
|
||||
}
|
||||
|
||||
public function loadAllLogs()
|
||||
{
|
||||
if (! $this->selectedExecution || ! $this->selectedExecution->message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = collect(explode("\n", $this->selectedExecution->message));
|
||||
$totalLines = $lines->count();
|
||||
$totalPages = ceil($totalLines / $this->logsPerPage);
|
||||
|
||||
$this->currentPage = $totalPages;
|
||||
}
|
||||
|
||||
public function getLogLinesProperty()
|
||||
{
|
||||
if (! $this->selectedExecution) {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public $uuid;
|
||||
|
||||
public $parameters;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $mount_path;
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path;
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source;
|
||||
|
||||
public string $file_storage_directory_destination;
|
||||
|
||||
public $rules = [
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
];
|
||||
|
||||
protected $listeners = ['clearAddStorage' => 'clear'];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mount_path' => 'mount',
|
||||
'host_path' => 'host',
|
||||
'file_storage_path' => 'file storage path',
|
||||
'file_storage_content' => 'file storage content',
|
||||
'file_storage_directory_source' => 'file storage directory source',
|
||||
'file_storage_directory_destination' => 'file storage directory destination',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
$this->uuid = $this->resource->uuid;
|
||||
$this->parameters = get_route_parameters();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$applicationUuid = $this->parameters['application_uuid'];
|
||||
$application = Application::where('uuid', $applicationUuid)->first();
|
||||
if (! $application) {
|
||||
abort(404);
|
||||
}
|
||||
if ($application->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
$this->rules['host_path'] = 'required|string';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
]);
|
||||
$name = $this->uuid.'-'.$this->name;
|
||||
$this->dispatch('addNewVolume', [
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,15 @@ class All extends Component
|
||||
public $resource;
|
||||
|
||||
protected $listeners = ['refreshStorages' => '$refresh'];
|
||||
|
||||
public function getFirstStorageIdProperty()
|
||||
{
|
||||
if ($this->resource->persistentStorages->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the storage with the smallest ID as the "first" one
|
||||
// This ensures stability even when storages are deleted
|
||||
return $this->resource->persistentStorages->sortBy('id')->first()->id;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -27,9 +24,6 @@ class Advanced extends Component
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $dynamicTimeout = 1;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isTerminalEnabled = false;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
@@ -42,37 +36,6 @@ class Advanced extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTerminal($password)
|
||||
{
|
||||
try {
|
||||
// Check if user is admin or owner
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
throw new \Exception('Only team administrators and owners can modify terminal access.');
|
||||
}
|
||||
|
||||
// Verify password unless two-step confirmation is disabled
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the terminal setting
|
||||
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
|
||||
$this->server->settings->save();
|
||||
|
||||
// Update the local property
|
||||
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
|
||||
|
||||
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
|
||||
$this->dispatch('success', "Terminal access has been {$status}.");
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
@@ -88,7 +51,6 @@ class Advanced extends Component
|
||||
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
|
||||
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
|
||||
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
|
||||
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class Navbar extends Component
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServerShow' => '$refresh',
|
||||
'refreshServerShow' => 'refreshServer',
|
||||
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
|
||||
];
|
||||
}
|
||||
@@ -134,6 +134,12 @@ class Navbar extends Component
|
||||
|
||||
}
|
||||
|
||||
public function refreshServer()
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->server->load('settings');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.navbar');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\PrivateKey;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -35,19 +36,20 @@ class Show extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalPrivateKeyId = $this->server->getOriginal('private_key_id');
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->update(['private_key_id' => $privateKeyId]);
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Private key updated successfully.');
|
||||
} else {
|
||||
throw new \Exception($error);
|
||||
}
|
||||
DB::transaction(function () use ($ownedPrivateKey) {
|
||||
$this->server->privateKey()->associate($ownedPrivateKey);
|
||||
$this->server->save();
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
|
||||
if (! $uptime) {
|
||||
throw new \Exception($error);
|
||||
}
|
||||
});
|
||||
$this->dispatch('success', 'Private key updated successfully.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Exception $e) {
|
||||
$this->server->update(['private_key_id' => $originalPrivateKeyId]);
|
||||
$this->server->refresh();
|
||||
$this->server->validateConnection();
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
@@ -59,6 +61,7 @@ class Show extends Component
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Server is reachable.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} else {
|
||||
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Proxy\CheckConfiguration;
|
||||
use App\Actions\Proxy\SaveConfiguration;
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -16,11 +16,11 @@ class Proxy extends Component
|
||||
|
||||
public ?string $selectedProxy = null;
|
||||
|
||||
public $proxy_settings = null;
|
||||
public $proxySettings = null;
|
||||
|
||||
public bool $redirect_enabled = true;
|
||||
public bool $redirectEnabled = true;
|
||||
|
||||
public ?string $redirect_url = null;
|
||||
public ?string $redirectUrl = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
@@ -39,14 +39,14 @@ class Proxy extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->selectedProxy = $this->server->proxyType();
|
||||
$this->redirect_enabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||
$this->redirect_url = data_get($this->server, 'proxy.redirect_url');
|
||||
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||
}
|
||||
|
||||
// public function proxyStatusUpdated()
|
||||
// {
|
||||
// $this->dispatch('refresh')->self();
|
||||
// }
|
||||
public function getConfigurationFilePathProperty()
|
||||
{
|
||||
return $this->server->proxyPath().'docker-compose.yml';
|
||||
}
|
||||
|
||||
public function changeProxy()
|
||||
{
|
||||
@@ -86,7 +86,7 @@ class Proxy extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->proxy->redirect_enabled = $this->redirect_enabled;
|
||||
$this->server->proxy->redirect_enabled = $this->redirectEnabled;
|
||||
$this->server->save();
|
||||
$this->server->setupDefaultRedirect();
|
||||
$this->dispatch('success', 'Proxy configuration saved.');
|
||||
@@ -99,8 +99,8 @@ class Proxy extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
SaveConfiguration::run($this->server, $this->proxy_settings);
|
||||
$this->server->proxy->redirect_url = $this->redirect_url;
|
||||
SaveProxyConfiguration::run($this->server, $this->proxySettings);
|
||||
$this->server->proxy->redirect_url = $this->redirectUrl;
|
||||
$this->server->save();
|
||||
$this->server->setupDefaultRedirect();
|
||||
$this->dispatch('success', 'Proxy configuration saved.');
|
||||
@@ -109,14 +109,15 @@ class Proxy extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function reset_proxy_configuration()
|
||||
public function resetProxyConfiguration()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->proxy_settings = CheckConfiguration::run($this->server, true);
|
||||
SaveConfiguration::run($this->server, $this->proxy_settings);
|
||||
// Explicitly regenerate default configuration
|
||||
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
|
||||
SaveProxyConfiguration::run($this->server, $this->proxySettings);
|
||||
$this->server->save();
|
||||
$this->dispatch('success', 'Proxy configuration saved.');
|
||||
$this->dispatch('success', 'Proxy configuration reset to default.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -125,7 +126,7 @@ class Proxy extends Component
|
||||
public function loadProxyConfiguration()
|
||||
{
|
||||
try {
|
||||
$this->proxy_settings = CheckConfiguration::run($this->server);
|
||||
$this->proxySettings = GetProxyConfiguration::run($this->server);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Security;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class TerminalAccess extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isTerminalEnabled = false;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('update', $this->server);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('server.index');
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTerminal($password)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
// Check if user is admin or owner
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
throw new \Exception('Only team administrators and owners can modify terminal access.');
|
||||
}
|
||||
|
||||
// Verify password unless two-step confirmation is disabled
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the terminal setting
|
||||
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
|
||||
$this->server->settings->save();
|
||||
|
||||
// Update the local property
|
||||
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
|
||||
|
||||
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
|
||||
$this->dispatch('success', "Terminal access has been {$status}.");
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
// No other fields to sync for terminal access
|
||||
} else {
|
||||
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.security.terminal-access');
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ class Show extends Component
|
||||
|
||||
public bool $isSentinelDebugEnabled;
|
||||
|
||||
public ?string $sentinelCustomDockerImage = null;
|
||||
|
||||
public string $serverTimezone;
|
||||
|
||||
public function getListeners()
|
||||
@@ -267,8 +269,9 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$this->server->restartSentinel();
|
||||
$this->dispatch('success', 'Restarting Sentinel.');
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
$this->server->restartSentinel($customImage);
|
||||
$this->dispatch('info', 'Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -295,12 +298,38 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsBuildServer($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($value === true && $this->isSentinelEnabled) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
$this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
|
||||
}
|
||||
$this->submit();
|
||||
// Dispatch event to refresh the navbar
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsSentinelEnabled($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if ($value === true) {
|
||||
StartSentinel::run($this->server, true);
|
||||
if ($this->isBuildServer) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
} else {
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
@@ -326,7 +355,7 @@ class Show extends Component
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->submit();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -336,7 +365,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
$this->dispatch('success', 'Server settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class ValidateAndInstall extends Component
|
||||
StartProxy::dispatch($this->server);
|
||||
} else {
|
||||
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
@@ -35,6 +35,12 @@ class Index extends Component
|
||||
#[Validate('required|string|timezone')]
|
||||
public string $instance_timezone;
|
||||
|
||||
public array $domainConflicts = [];
|
||||
|
||||
public bool $showDomainConflictModal = false;
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.index');
|
||||
@@ -81,6 +87,13 @@ class Index extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -102,13 +115,24 @@ class Index extends Component
|
||||
$this->validate();
|
||||
|
||||
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
|
||||
if (! validate_dns_entry($this->fqdn, $this->server)) {
|
||||
if (! validateDNSEntry($this->fqdn, $this->server)) {
|
||||
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
$error_show = true;
|
||||
}
|
||||
}
|
||||
if ($this->fqdn) {
|
||||
check_domain_usage(domain: $this->fqdn);
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(domain: $this->fqdn);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->instantSave(isSave: false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Services\ChangelogService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
@@ -23,6 +23,11 @@ class SettingsDropdown extends Component
|
||||
return app(ChangelogService::class)->getEntriesForUser($user);
|
||||
}
|
||||
|
||||
public function getCurrentVersionProperty()
|
||||
{
|
||||
return 'v'.config('constants.coolify.version');
|
||||
}
|
||||
|
||||
public function openWhatsNewModal()
|
||||
{
|
||||
$this->showWhatsNewModal = true;
|
||||
@@ -50,7 +55,7 @@ class SettingsDropdown extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
PullChangelogFromGitHub::dispatch();
|
||||
PullChangelog::dispatch();
|
||||
$this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage());
|
||||
@@ -62,6 +67,7 @@ class SettingsDropdown extends Component
|
||||
return view('livewire.settings-dropdown', [
|
||||
'entries' => $this->entries,
|
||||
'unreadCount' => $this->unreadCount,
|
||||
'currentVersion' => $this->currentVersion,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ class InviteLink extends Component
|
||||
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
|
||||
throw new \Exception('Admins cannot invite owners.');
|
||||
}
|
||||
$this->email = strtolower($this->email);
|
||||
|
||||
$member_emails = currentTeam()->members()->get()->pluck('email');
|
||||
if ($member_emails->contains($this->email)) {
|
||||
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
|
||||
|
||||
+289
-102
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Application extends BaseModel
|
||||
{
|
||||
use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -123,6 +124,82 @@ class Application extends BaseModel
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
$builder->withCount([
|
||||
'additional_servers',
|
||||
'additional_networks',
|
||||
]);
|
||||
});
|
||||
static::saving(function ($application) {
|
||||
$payload = [];
|
||||
if ($application->isDirty('fqdn')) {
|
||||
if ($application->fqdn === '') {
|
||||
$application->fqdn = null;
|
||||
}
|
||||
$payload['fqdn'] = $application->fqdn;
|
||||
}
|
||||
if ($application->isDirty('install_command')) {
|
||||
$payload['install_command'] = str($application->install_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('build_command')) {
|
||||
$payload['build_command'] = str($application->build_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('start_command')) {
|
||||
$payload['start_command'] = str($application->start_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('base_directory')) {
|
||||
$payload['base_directory'] = str($application->base_directory)->trim();
|
||||
}
|
||||
if ($application->isDirty('publish_directory')) {
|
||||
$payload['publish_directory'] = str($application->publish_directory)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_repository')) {
|
||||
$payload['git_repository'] = str($application->git_repository)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_branch')) {
|
||||
$payload['git_branch'] = str($application->git_branch)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_commit_sha')) {
|
||||
$payload['git_commit_sha'] = str($application->git_commit_sha)->trim();
|
||||
}
|
||||
if ($application->isDirty('status')) {
|
||||
$payload['last_online_at'] = now();
|
||||
}
|
||||
if ($application->isDirty('custom_nginx_configuration')) {
|
||||
if ($application->custom_nginx_configuration === '') {
|
||||
$payload['custom_nginx_configuration'] = null;
|
||||
}
|
||||
}
|
||||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
}
|
||||
});
|
||||
static::created(function ($application) {
|
||||
ApplicationSetting::create([
|
||||
'application_id' => $application->id,
|
||||
]);
|
||||
$application->compose_parsing_version = self::$parserVersion;
|
||||
$application->save();
|
||||
});
|
||||
static::forceDeleting(function ($application) {
|
||||
$application->update(['fqdn' => null]);
|
||||
$application->settings()->delete();
|
||||
$application->persistentStorages()->delete();
|
||||
$application->environment_variables()->delete();
|
||||
$application->environment_variables_preview()->delete();
|
||||
foreach ($application->scheduled_tasks as $task) {
|
||||
$task->delete();
|
||||
}
|
||||
$application->tags()->detach();
|
||||
$application->previews()->delete();
|
||||
foreach ($application->deployment_queue as $deployment) {
|
||||
$deployment->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function customNetworkAliases(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -183,73 +260,6 @@ class Application extends BaseModel
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
$builder->withCount([
|
||||
'additional_servers',
|
||||
'additional_networks',
|
||||
]);
|
||||
});
|
||||
static::saving(function ($application) {
|
||||
$payload = [];
|
||||
if ($application->isDirty('fqdn')) {
|
||||
if ($application->fqdn === '') {
|
||||
$application->fqdn = null;
|
||||
}
|
||||
$payload['fqdn'] = $application->fqdn;
|
||||
}
|
||||
if ($application->isDirty('install_command')) {
|
||||
$payload['install_command'] = str($application->install_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('build_command')) {
|
||||
$payload['build_command'] = str($application->build_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('start_command')) {
|
||||
$payload['start_command'] = str($application->start_command)->trim();
|
||||
}
|
||||
if ($application->isDirty('base_directory')) {
|
||||
$payload['base_directory'] = str($application->base_directory)->trim();
|
||||
}
|
||||
if ($application->isDirty('publish_directory')) {
|
||||
$payload['publish_directory'] = str($application->publish_directory)->trim();
|
||||
}
|
||||
if ($application->isDirty('status')) {
|
||||
$payload['last_online_at'] = now();
|
||||
}
|
||||
if ($application->isDirty('custom_nginx_configuration')) {
|
||||
if ($application->custom_nginx_configuration === '') {
|
||||
$payload['custom_nginx_configuration'] = null;
|
||||
}
|
||||
}
|
||||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
}
|
||||
});
|
||||
static::created(function ($application) {
|
||||
ApplicationSetting::create([
|
||||
'application_id' => $application->id,
|
||||
]);
|
||||
$application->compose_parsing_version = self::$parserVersion;
|
||||
$application->save();
|
||||
});
|
||||
static::forceDeleting(function ($application) {
|
||||
$application->update(['fqdn' => null]);
|
||||
$application->settings()->delete();
|
||||
$application->persistentStorages()->delete();
|
||||
$application->environment_variables()->delete();
|
||||
$application->environment_variables_preview()->delete();
|
||||
foreach ($application->scheduled_tasks as $task) {
|
||||
$task->delete();
|
||||
}
|
||||
$application->tags()->detach();
|
||||
$application->previews()->delete();
|
||||
foreach ($application->deployment_queue as $deployment) {
|
||||
$deployment->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
|
||||
@@ -728,7 +738,14 @@ class Application extends BaseModel
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->orderBy('key', 'asc');
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables()
|
||||
@@ -738,14 +755,6 @@ class Application extends BaseModel
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function build_environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->where('is_build_time', true)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
@@ -757,7 +766,14 @@ class Application extends BaseModel
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
");
|
||||
}
|
||||
|
||||
public function runtime_environment_variables_preview()
|
||||
@@ -767,14 +783,6 @@ class Application extends BaseModel
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function build_environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->where('is_build_time', true)
|
||||
->where('key', 'not like', 'NIXPACKS_%');
|
||||
}
|
||||
|
||||
public function nixpacks_environment_variables_preview()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
@@ -934,11 +942,11 @@ class Application extends BaseModel
|
||||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$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->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->custom_labels);
|
||||
$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->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->custom_labels.$this->settings->use_build_secrets);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
} else {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get('value')->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
@@ -1478,17 +1486,18 @@ class Application extends BaseModel
|
||||
$this->save();
|
||||
$parsedServices = $this->parse();
|
||||
if ($this->docker_compose_domains) {
|
||||
$json = collect(json_decode($this->docker_compose_domains));
|
||||
$decoded = json_decode($this->docker_compose_domains, true);
|
||||
$json = collect(is_array($decoded) ? $decoded : []);
|
||||
$normalized = collect();
|
||||
foreach ($json as $key => $value) {
|
||||
if (str($key)->contains('-')) {
|
||||
$key = str($key)->replace('-', '_');
|
||||
}
|
||||
$json->put((string) $key, $value);
|
||||
$normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_');
|
||||
$normalized->put($normalizedKey, $value);
|
||||
}
|
||||
$json = $normalized;
|
||||
$services = collect(data_get($parsedServices, 'services', []));
|
||||
foreach ($services as $name => $service) {
|
||||
if (str($name)->contains('-')) {
|
||||
$replacedName = str($name)->replace('-', '_');
|
||||
if (str($name)->contains('-') || str($name)->contains('.')) {
|
||||
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
|
||||
$services->put((string) $replacedName, $service);
|
||||
$services->forget((string) $name);
|
||||
}
|
||||
@@ -1556,28 +1565,206 @@ class Application extends BaseModel
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function parseWatchPaths($value)
|
||||
{
|
||||
if ($value) {
|
||||
$watch_paths = collect(explode("\n", $value))
|
||||
->map(function (string $path): string {
|
||||
// Trim whitespace
|
||||
$path = trim($path);
|
||||
|
||||
if (str_starts_with($path, '!')) {
|
||||
$negation = '!';
|
||||
$pathWithoutNegation = substr($path, 1);
|
||||
$pathWithoutNegation = ltrim(trim($pathWithoutNegation), '/');
|
||||
|
||||
return $negation.$pathWithoutNegation;
|
||||
}
|
||||
|
||||
return ltrim($path, '/');
|
||||
})
|
||||
->filter(function (string $path): bool {
|
||||
return strlen($path) > 0;
|
||||
});
|
||||
|
||||
return trim($watch_paths->implode("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
public function watchPaths(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if ($value) {
|
||||
return trim($value);
|
||||
return $this->parseWatchPaths($value);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
|
||||
{
|
||||
return self::matchPaths($modified_files, $watch_paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to match paths against watch patterns with negation support
|
||||
* Uses order-based matching: last matching pattern wins
|
||||
*/
|
||||
public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
|
||||
{
|
||||
if (is_null($watch_paths) || $watch_paths->isEmpty()) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
return $modified_files->filter(function ($file) use ($watch_paths) {
|
||||
$shouldInclude = null; // null means no patterns matched
|
||||
|
||||
// Process patterns in order - last match wins
|
||||
foreach ($watch_paths as $pattern) {
|
||||
$pattern = trim($pattern);
|
||||
if (empty($pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isExclusion = str_starts_with($pattern, '!');
|
||||
$matchPattern = $isExclusion ? substr($pattern, 1) : $pattern;
|
||||
|
||||
if (self::globMatch($matchPattern, $file)) {
|
||||
// This pattern matches - it determines the current state
|
||||
$shouldInclude = ! $isExclusion;
|
||||
}
|
||||
}
|
||||
|
||||
// If no patterns matched and we only have exclusion patterns, include by default
|
||||
if ($shouldInclude === null) {
|
||||
// Check if we only have exclusion patterns
|
||||
$hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!'));
|
||||
|
||||
return ! $hasInclusionPatterns;
|
||||
}
|
||||
|
||||
return $shouldInclude;
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a glob pattern
|
||||
* Supports: *, **, ?, [abc], [!abc]
|
||||
*/
|
||||
public static function globMatch(string $pattern, string $path): bool
|
||||
{
|
||||
$regex = self::globToRegex($pattern);
|
||||
|
||||
return preg_match($regex, $path) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a regular expression
|
||||
*/
|
||||
public static function globToRegex(string $pattern): string
|
||||
{
|
||||
$regex = '';
|
||||
$inGroup = false;
|
||||
$chars = str_split($pattern);
|
||||
$len = count($chars);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$c = $chars[$i];
|
||||
|
||||
switch ($c) {
|
||||
case '*':
|
||||
// Check for **
|
||||
if ($i + 1 < $len && $chars[$i + 1] === '*') {
|
||||
// ** matches any number of directories
|
||||
$regex .= '.*';
|
||||
$i++; // Skip next *
|
||||
// Skip optional /
|
||||
if ($i + 1 < $len && $chars[$i + 1] === '/') {
|
||||
$i++;
|
||||
}
|
||||
} else {
|
||||
// * matches anything except /
|
||||
$regex .= '[^/]*';
|
||||
}
|
||||
break;
|
||||
|
||||
case '?':
|
||||
// ? matches any single character except /
|
||||
$regex .= '[^/]';
|
||||
break;
|
||||
|
||||
case '[':
|
||||
// Character class
|
||||
$inGroup = true;
|
||||
$regex .= '[';
|
||||
// Check for negation
|
||||
if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) {
|
||||
$regex .= '^';
|
||||
$i++;
|
||||
}
|
||||
break;
|
||||
|
||||
case ']':
|
||||
if ($inGroup) {
|
||||
$inGroup = false;
|
||||
$regex .= ']';
|
||||
} else {
|
||||
$regex .= preg_quote($c, '#');
|
||||
}
|
||||
break;
|
||||
|
||||
case '.':
|
||||
case '(':
|
||||
case ')':
|
||||
case '+':
|
||||
case '{':
|
||||
case '}':
|
||||
case '$':
|
||||
case '^':
|
||||
case '|':
|
||||
case '\\':
|
||||
// Escape regex special characters
|
||||
$regex .= '\\'.$c;
|
||||
break;
|
||||
|
||||
default:
|
||||
$regex .= $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in delimiters and anchors
|
||||
return '#^'.$regex.'$#';
|
||||
}
|
||||
|
||||
public function normalizeWatchPaths(): void
|
||||
{
|
||||
if (is_null($this->watch_paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->parseWatchPaths($this->watch_paths);
|
||||
if ($normalized !== $this->watch_paths) {
|
||||
$this->watch_paths = $normalized;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function isWatchPathsTriggered(Collection $modified_files): bool
|
||||
{
|
||||
if (is_null($this->watch_paths)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->normalizeWatchPaths();
|
||||
|
||||
$watch_paths = collect(explode("\n", $this->watch_paths));
|
||||
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
|
||||
return $watch_paths->contains(function ($glob) use ($file) {
|
||||
return fnmatch($glob, $file);
|
||||
});
|
||||
});
|
||||
|
||||
if ($watch_paths->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
$matches = $this->matchWatchPaths($modified_files, $watch_paths);
|
||||
|
||||
return $matches->count() > 0;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model
|
||||
return str($this->commit_message)->value();
|
||||
}
|
||||
|
||||
private function redactSensitiveInfo($text)
|
||||
{
|
||||
$text = remove_iip($text);
|
||||
|
||||
$app = $this->application;
|
||||
if (! $app) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$lockedVars = collect([]);
|
||||
|
||||
if ($app->environment_variables) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$app->environment_variables
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$app->environment_variables_preview
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($lockedVars as $key => $value) {
|
||||
$escapedValue = preg_quote($value, '/');
|
||||
$text = preg_replace(
|
||||
'/'.$escapedValue.'/',
|
||||
REDACTED,
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
|
||||
{
|
||||
if ($type === 'error') {
|
||||
@@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model
|
||||
}
|
||||
$newLogEntry = [
|
||||
'command' => null,
|
||||
'output' => remove_iip($message),
|
||||
'output' => $this->redactSensitiveInfo($message),
|
||||
'type' => $type,
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => $hidden,
|
||||
|
||||
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user