Merge branch 'next' into patch-1
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
47
app/Actions/Proxy/GetProxyConfiguration.php
Normal file
47
app/Actions/Proxy/GetProxyConfiguration.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
151
app/Actions/Stripe/CancelSubscription.php
Normal file
151
app/Actions/Stripe/CancelSubscription.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Actions/User/DeleteUserResources.php
Normal file
125
app/Actions/User/DeleteUserResources.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
77
app/Actions/User/DeleteUserServers.php
Normal file
77
app/Actions/User/DeleteUserServers.php
Normal file
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
app/Actions/User/DeleteUserTeams.php
Normal file
202
app/Actions/User/DeleteUserTeams.php
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
722
app/Console/Commands/CloudDeleteUser.php
Normal file
722
app/Console/Commands/CloudDeleteUser.php
Normal file
@@ -0,0 +1,722 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
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\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;
|
||||
}
|
||||
|
||||
$this->logAction("Starting user deletion process for: {$email}");
|
||||
|
||||
// Phase 1: Show User Overview (outside transaction)
|
||||
if (! $this->showUserOverview()) {
|
||||
$this->info('User deletion cancelled.');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
// Map plan IDs to monthly values based on config
|
||||
$subscriptionConfigs = config('subscription');
|
||||
|
||||
foreach ($subscriptionConfigs as $key => $value) {
|
||||
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
|
||||
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
|
||||
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
|
||||
|
||||
// Map to known prices (you may need to adjust these based on your actual pricing)
|
||||
return match ($planType) {
|
||||
'basic' => 29,
|
||||
'pro' => 49,
|
||||
'ultimate' => 99,
|
||||
default => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
35
app/Events/ApplicationConfigurationChanged.php
Normal file
35
app/Events/ApplicationConfigurationChanged.php
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
31
app/Exceptions/NonReportableException.php
Normal file
31
app/Exceptions/NonReportableException.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Helpers/SshRetryHandler.php
Normal file
34
app/Helpers/SshRetryHandler.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2429,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.'],
|
||||
@@ -2470,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)) {
|
||||
@@ -2495,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',
|
||||
@@ -2516,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;
|
||||
}
|
||||
@@ -2538,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);
|
||||
@@ -2550,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;
|
||||
}
|
||||
@@ -2565,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);
|
||||
@@ -2619,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.'],
|
||||
@@ -2690,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) {
|
||||
@@ -2698,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',
|
||||
@@ -2710,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;
|
||||
@@ -2719,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;
|
||||
}
|
||||
@@ -2731,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,
|
||||
]);
|
||||
@@ -2749,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;
|
||||
}
|
||||
@@ -2761,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,
|
||||
]);
|
||||
@@ -2814,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.'],
|
||||
@@ -2854,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)) {
|
||||
@@ -2874,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',
|
||||
@@ -2908,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,
|
||||
]);
|
||||
@@ -2931,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,
|
||||
]);
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -355,7 +359,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 +370,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 +468,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 +494,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 +514,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 +534,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 +548,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 +602,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();
|
||||
@@ -95,7 +95,7 @@ class StripeProcessJob implements ShouldQueue
|
||||
$customerId = data_get($data, 'customer');
|
||||
$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();
|
||||
@@ -110,16 +110,38 @@ class StripeProcessJob implements ShouldQueue
|
||||
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 +151,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 +176,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 +199,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 +216,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 +252,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 +260,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 +295,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', [
|
||||
|
||||
372
app/Livewire/GlobalSearch.php
Normal file
372
app/Livewire/GlobalSearch.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
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\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),
|
||||
];
|
||||
});
|
||||
|
||||
// Merge all collections
|
||||
$items = $items->merge($applications)
|
||||
->merge($services)
|
||||
->merge($databases)
|
||||
->merge($servers);
|
||||
|
||||
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;
|
||||
|
||||
@@ -52,15 +52,24 @@ class DeploymentNavbar extends Component
|
||||
|
||||
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 +86,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);
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ 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.");
|
||||
}
|
||||
}
|
||||
@@ -615,7 +615,7 @@ 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.");
|
||||
}
|
||||
}
|
||||
@@ -671,7 +671,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)
|
||||
@@ -703,7 +703,6 @@ class General extends Component
|
||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -712,7 +711,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
|
||||
@@ -721,7 +719,6 @@ class General extends Component
|
||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -729,7 +726,6 @@ class General extends Component
|
||||
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ 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;
|
||||
}
|
||||
@@ -231,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);
|
||||
|
||||
@@ -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 = generateUrl(server: $this->server, random: $uuid);
|
||||
}
|
||||
|
||||
$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(),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -19,28 +19,32 @@ 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;
|
||||
|
||||
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()
|
||||
@@ -54,9 +58,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 +71,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,16 @@ 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;
|
||||
@@ -55,10 +57,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',
|
||||
];
|
||||
@@ -101,8 +104,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 +118,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 +133,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 +144,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()
|
||||
|
||||
@@ -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 = generateUrl(server: $server, random: $uuid);
|
||||
}
|
||||
|
||||
$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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ class Show extends Component
|
||||
|
||||
public bool $isSentinelDebugEnabled;
|
||||
|
||||
public ?string $sentinelCustomDockerImage = null;
|
||||
|
||||
public string $serverTimezone;
|
||||
|
||||
public function getListeners()
|
||||
@@ -267,7 +269,8 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$this->server->restartSentinel();
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
$this->server->restartSentinel($customImage);
|
||||
$this->dispatch('success', '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;
|
||||
|
||||
@@ -115,7 +115,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,66 +124,6 @@ class Application extends BaseModel
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
];
|
||||
|
||||
public function customNetworkAliases(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's already a JSON string, decode it
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
$value = json_decode($value, true);
|
||||
}
|
||||
|
||||
// If it's a string but not JSON, treat it as a comma-separated list
|
||||
if (is_string($value) && ! is_array($value)) {
|
||||
$value = explode(',', $value);
|
||||
}
|
||||
|
||||
$value = collect($value)
|
||||
->map(function ($alias) {
|
||||
if (is_string($alias)) {
|
||||
return str_replace(' ', '-', trim($alias));
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->unique() // Remove duplicate values
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return empty($value) ? null : json_encode($value);
|
||||
},
|
||||
get: function ($value) {
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid JSON
|
||||
*/
|
||||
private function isJson($string)
|
||||
{
|
||||
if (! is_string($string)) {
|
||||
return false;
|
||||
}
|
||||
json_decode($string);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
@@ -250,6 +191,66 @@ class Application extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public function customNetworkAliases(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's already a JSON string, decode it
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
$value = json_decode($value, true);
|
||||
}
|
||||
|
||||
// If it's a string but not JSON, treat it as a comma-separated list
|
||||
if (is_string($value) && ! is_array($value)) {
|
||||
$value = explode(',', $value);
|
||||
}
|
||||
|
||||
$value = collect($value)
|
||||
->map(function ($alias) {
|
||||
if (is_string($alias)) {
|
||||
return str_replace(' ', '-', trim($alias));
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->unique() // Remove duplicate values
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return empty($value) ? null : json_encode($value);
|
||||
},
|
||||
get: function ($value) {
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid JSON
|
||||
*/
|
||||
private function isJson($string)
|
||||
{
|
||||
if (! is_string($string)) {
|
||||
return false;
|
||||
}
|
||||
json_decode($string);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
|
||||
@@ -728,7 +729,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 +746,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 +757,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 +774,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 +933,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');
|
||||
@@ -1481,14 +1480,14 @@ class Application extends BaseModel
|
||||
$json = collect(json_decode($this->docker_compose_domains));
|
||||
foreach ($json as $key => $value) {
|
||||
if (str($key)->contains('-')) {
|
||||
$key = str($key)->replace('-', '_');
|
||||
$key = str($key)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
$json->put((string) $key, $value);
|
||||
}
|
||||
$services = collect(data_get($parsedServices, 'services', []));
|
||||
foreach ($services as $name => $service) {
|
||||
if (str($name)->contains('-')) {
|
||||
$replacedName = str($name)->replace('-', '_');
|
||||
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
|
||||
$services->put((string) $replacedName, $service);
|
||||
$services->forget((string) $name);
|
||||
}
|
||||
@@ -1572,7 +1571,19 @@ class Application extends BaseModel
|
||||
if (is_null($this->watch_paths)) {
|
||||
return false;
|
||||
}
|
||||
$watch_paths = collect(explode("\n", $this->watch_paths));
|
||||
$watch_paths = collect(explode("\n", $this->watch_paths))
|
||||
->map(function (string $path): string {
|
||||
return trim($path);
|
||||
})
|
||||
->filter(function (string $path): bool {
|
||||
return strlen($path) > 0;
|
||||
});
|
||||
|
||||
// If no valid patterns after filtering, don't trigger
|
||||
if ($watch_paths->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
|
||||
return $watch_paths->contains(function ($glob) use ($file) {
|
||||
return fnmatch($glob, $file);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ class ApplicationSetting extends Model
|
||||
'is_force_https_enabled' => 'boolean',
|
||||
'is_debug_enabled' => 'boolean',
|
||||
'is_preview_deployments_enabled' => 'boolean',
|
||||
'is_pr_deployments_public_enabled' => 'boolean',
|
||||
'is_git_submodules_enabled' => 'boolean',
|
||||
'is_git_lfs_enabled' => 'boolean',
|
||||
'is_git_shallow_clone_enabled' => 'boolean',
|
||||
|
||||
@@ -14,10 +14,11 @@ use OpenApi\Attributes as OA;
|
||||
'uuid' => ['type' => 'string'],
|
||||
'resourceable_type' => ['type' => 'string'],
|
||||
'resourceable_id' => ['type' => 'integer'],
|
||||
'is_build_time' => ['type' => 'boolean'],
|
||||
'is_literal' => ['type' => 'boolean'],
|
||||
'is_multiline' => ['type' => 'boolean'],
|
||||
'is_preview' => ['type' => 'boolean'],
|
||||
'is_runtime' => ['type' => 'boolean'],
|
||||
'is_buildtime' => ['type' => 'boolean'],
|
||||
'is_shared' => ['type' => 'boolean'],
|
||||
'is_shown_once' => ['type' => 'boolean'],
|
||||
'key' => ['type' => 'string'],
|
||||
@@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
'value' => 'encrypted',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_preview' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'version' => 'string',
|
||||
'resourceable_type' => 'string',
|
||||
'resourceable_id' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required'];
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
@@ -61,8 +63,8 @@ class EnvironmentVariable extends BaseModel
|
||||
ModelsEnvironmentVariable::create([
|
||||
'key' => $environment_variable->key,
|
||||
'value' => $environment_variable->value,
|
||||
'is_build_time' => $environment_variable->is_build_time,
|
||||
'is_multiline' => $environment_variable->is_multiline ?? false,
|
||||
'is_literal' => $environment_variable->is_literal ?? false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $environment_variable->resourceable_id,
|
||||
'is_preview' => true,
|
||||
@@ -137,6 +139,32 @@ class EnvironmentVariable extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
protected function isNixpacks(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (str($this->key)->startsWith('NIXPACKS_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function isCoolify(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (str($this->key)->startsWith('SERVICE_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function isShared(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Kubernetes extends BaseModel {}
|
||||
@@ -119,6 +119,7 @@ class LocalFileVolume extends BaseModel
|
||||
$commands = collect([]);
|
||||
if ($this->is_directory) {
|
||||
$commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true");
|
||||
$commands->push("mkdir -p $workdir > /dev/null 2>&1 || true");
|
||||
$commands->push("cd $workdir");
|
||||
}
|
||||
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -99,11 +100,18 @@ class PrivateKey extends BaseModel
|
||||
|
||||
public static function createAndStore(array $data)
|
||||
{
|
||||
$privateKey = new self($data);
|
||||
$privateKey->save();
|
||||
$privateKey->storeInFileSystem();
|
||||
return DB::transaction(function () use ($data) {
|
||||
$privateKey = new self($data);
|
||||
$privateKey->save();
|
||||
|
||||
return $privateKey;
|
||||
try {
|
||||
$privateKey->storeInFileSystem();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Failed to store SSH key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $privateKey;
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateNewKeyPair($type = 'rsa')
|
||||
@@ -151,15 +159,64 @@ class PrivateKey extends BaseModel
|
||||
public function storeInFileSystem()
|
||||
{
|
||||
$filename = "ssh_key@{$this->uuid}";
|
||||
Storage::disk('ssh-keys')->put($filename, $this->private_key);
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
|
||||
return "/var/www/html/storage/app/ssh/keys/{$filename}";
|
||||
// Ensure the storage directory exists and is writable
|
||||
$this->ensureStorageDirectoryExists();
|
||||
|
||||
// Attempt to store the private key
|
||||
$success = $disk->put($filename, $this->private_key);
|
||||
|
||||
if (! $success) {
|
||||
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
// Verify the file was actually created and has content
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
$storedContent = $disk->get($filename);
|
||||
if (empty($storedContent) || $storedContent !== $this->private_key) {
|
||||
$disk->delete($filename); // Clean up the bad file
|
||||
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
|
||||
}
|
||||
|
||||
return $this->getKeyLocation();
|
||||
}
|
||||
|
||||
public static function deleteFromStorage(self $privateKey)
|
||||
{
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
Storage::disk('ssh-keys')->delete($filename);
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
|
||||
if ($disk->exists($filename)) {
|
||||
$disk->delete($filename);
|
||||
}
|
||||
}
|
||||
|
||||
protected function ensureStorageDirectoryExists()
|
||||
{
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$directoryPath = '';
|
||||
|
||||
if (! $disk->exists($directoryPath)) {
|
||||
$success = $disk->makeDirectory($directoryPath);
|
||||
if (! $success) {
|
||||
throw new \Exception('Failed to create SSH keys storage directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if directory is writable by attempting a test file
|
||||
$testFilename = '.test_write_'.uniqid();
|
||||
$testSuccess = $disk->put($testFilename, 'test');
|
||||
|
||||
if (! $testSuccess) {
|
||||
throw new \Exception('SSH keys storage directory is not writable');
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
$disk->delete($testFilename);
|
||||
}
|
||||
|
||||
public function getKeyLocation()
|
||||
@@ -169,10 +226,17 @@ class PrivateKey extends BaseModel
|
||||
|
||||
public function updatePrivateKey(array $data)
|
||||
{
|
||||
$this->update($data);
|
||||
$this->storeInFileSystem();
|
||||
return DB::transaction(function () use ($data) {
|
||||
$this->update($data);
|
||||
|
||||
return $this;
|
||||
try {
|
||||
$this->storeInFileSystem();
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this;
|
||||
});
|
||||
}
|
||||
|
||||
public function servers()
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Server extends BaseModel
|
||||
{
|
||||
use HasFactory, SchemalessAttributesTrait, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
|
||||
|
||||
public static $batch_counter = 0;
|
||||
|
||||
@@ -1259,13 +1260,13 @@ $schema://$host {
|
||||
return str($this->ip)->contains(':');
|
||||
}
|
||||
|
||||
public function restartSentinel(bool $async = true)
|
||||
public function restartSentinel(?string $customImage = null, bool $async = true)
|
||||
{
|
||||
try {
|
||||
if ($async) {
|
||||
StartSentinel::dispatch($this, true);
|
||||
StartSentinel::dispatch($this, true, null, $customImage);
|
||||
} else {
|
||||
StartSentinel::run($this, true);
|
||||
StartSentinel::run($this, true, null, $customImage);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2;
|
||||
)]
|
||||
class Service extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -1113,7 +1114,6 @@ class Service extends BaseModel
|
||||
$this->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_build_time' => false,
|
||||
'resourceable_id' => $this->id,
|
||||
'resourceable_type' => $this->getMorphClass(),
|
||||
'is_preview' => false,
|
||||
@@ -1230,14 +1230,14 @@ class Service extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderBy('key', 'asc');
|
||||
}
|
||||
|
||||
public function environment_variables_preview()
|
||||
{
|
||||
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 workdir()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -28,7 +29,6 @@ class StandaloneClickhouse extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -44,6 +44,11 @@ class StandaloneClickhouse extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -267,7 +272,14 @@ class StandaloneClickhouse extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneDragonfly extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -28,7 +29,6 @@ class StandaloneDragonfly extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -44,6 +44,11 @@ class StandaloneDragonfly extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -342,6 +347,13 @@ class StandaloneDragonfly extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneKeydb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -28,7 +29,6 @@ class StandaloneKeydb extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -44,6 +44,11 @@ class StandaloneKeydb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -342,6 +347,13 @@ class StandaloneKeydb extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMariadb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -29,7 +30,6 @@ class StandaloneMariadb extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -45,6 +45,11 @@ class StandaloneMariadb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -263,7 +268,14 @@ class StandaloneMariadb extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMongodb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -24,7 +25,6 @@ class StandaloneMongodb extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'mongodb-db-'.$database->uuid,
|
||||
@@ -32,7 +32,6 @@ class StandaloneMongodb extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -48,6 +47,11 @@ class StandaloneMongodb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -365,6 +369,13 @@ class StandaloneMongodb extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMysql extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -29,7 +30,6 @@ class StandaloneMysql extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -45,6 +45,11 @@ class StandaloneMysql extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -346,6 +351,13 @@ class StandaloneMysql extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandalonePostgresql extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -29,7 +30,6 @@ class StandalonePostgresql extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -45,6 +45,11 @@ class StandalonePostgresql extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public function workdir()
|
||||
{
|
||||
return database_configuration_dir()."/{$this->uuid}";
|
||||
@@ -297,7 +302,14 @@ class StandalonePostgresql extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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 isBackupSolutionAvailable()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -24,7 +25,6 @@ class StandaloneRedis extends BaseModel
|
||||
'host_path' => null,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
'is_readonly' => true,
|
||||
]);
|
||||
});
|
||||
static::forceDeleting(function ($database) {
|
||||
@@ -46,6 +46,11 @@ class StandaloneRedis extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -389,6 +394,13 @@ class StandaloneRedis extends BaseModel
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->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
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,22 @@ class User extends Authenticatable implements SendsEmail
|
||||
'email_change_code_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Set the email attribute to lowercase.
|
||||
*/
|
||||
public function setEmailAttribute($value)
|
||||
{
|
||||
$this->attributes['email'] = strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the pending_email attribute to lowercase.
|
||||
*/
|
||||
public function setPendingEmailAttribute($value)
|
||||
{
|
||||
$this->attributes['pending_email'] = $value ? strtolower($value) : null;
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Webhook extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'type' => 'string',
|
||||
'payload' => 'encrypted',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use App\Exceptions\NonReportableException;
|
||||
use App\Models\Team;
|
||||
use Exception;
|
||||
use Illuminate\Notifications\Notification;
|
||||
@@ -101,13 +102,11 @@ class EmailChannel
|
||||
$mailer->send($email);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('EmailChannel failed: '.$e->getMessage(), [
|
||||
'notification' => get_class($notification),
|
||||
'notifiable' => get_class($notifiable),
|
||||
'team_id' => data_get($notifiable, 'id'),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
// Check if this is a Resend domain verification error on cloud instances
|
||||
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
|
||||
// Throw as NonReportableException so it won't go to Sentry
|
||||
throw NonReportableException::fromException($e);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,23 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
) {
|
||||
$user->updated_at = now();
|
||||
$user->save();
|
||||
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
|
||||
if (! $user->currentTeam) {
|
||||
$user->currentTeam = $user->recreate_personal_team();
|
||||
|
||||
// Check if user has a pending invitation they haven't accepted yet
|
||||
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
|
||||
if ($invitation && $invitation->isValid()) {
|
||||
// User is logging in for the first time after being invited
|
||||
// Attach them to the invited team if not already attached
|
||||
if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) {
|
||||
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$user->currentTeam = $invitation->team;
|
||||
$invitation->delete();
|
||||
} else {
|
||||
// Normal login - use personal team
|
||||
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
|
||||
if (! $user->currentTeam) {
|
||||
$user->currentTeam = $user->recreate_personal_team();
|
||||
}
|
||||
}
|
||||
session(['currentTeam' => $user->currentTeam]);
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ class ConfigurationGenerator
|
||||
$variables->push([
|
||||
'key' => $env->key,
|
||||
'value' => $env->value,
|
||||
'is_build_time' => $env->is_build_time,
|
||||
'is_preview' => $env->is_preview,
|
||||
'is_multiline' => $env->is_multiline,
|
||||
]);
|
||||
@@ -145,7 +144,6 @@ class ConfigurationGenerator
|
||||
$variables->push([
|
||||
'key' => $env->key,
|
||||
'value' => $env->value,
|
||||
'is_build_time' => $env->is_build_time,
|
||||
'is_preview' => $env->is_preview,
|
||||
'is_multiline' => $env->is_multiline,
|
||||
]);
|
||||
|
||||
81
app/Traits/ClearsGlobalSearchCache.php
Normal file
81
app/Traits/ClearsGlobalSearchCache.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
|
||||
trait ClearsGlobalSearchCache
|
||||
{
|
||||
protected static function bootClearsGlobalSearchCache()
|
||||
{
|
||||
static::saving(function ($model) {
|
||||
// Only clear cache if searchable fields are being changed
|
||||
if ($model->hasSearchableChanges()) {
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::created(function ($model) {
|
||||
// Always clear cache when model is created
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($model) {
|
||||
// Always clear cache when model is deleted
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hasSearchableChanges(): bool
|
||||
{
|
||||
// Define searchable fields based on model type
|
||||
$searchableFields = ['name', 'description'];
|
||||
|
||||
// Add model-specific searchable fields
|
||||
if ($this instanceof \App\Models\Application) {
|
||||
$searchableFields[] = 'fqdn';
|
||||
$searchableFields[] = 'docker_compose_domains';
|
||||
} elseif ($this instanceof \App\Models\Server) {
|
||||
$searchableFields[] = 'ip';
|
||||
} elseif ($this instanceof \App\Models\Service) {
|
||||
// Services don't have direct fqdn, but name and description are covered
|
||||
}
|
||||
// Database models only have name and description as searchable
|
||||
|
||||
// Check if any searchable field is dirty
|
||||
foreach ($searchableFields as $field) {
|
||||
if ($this->isDirty($field)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getTeamIdForCache()
|
||||
{
|
||||
// For database models, team is accessed through environment.project.team
|
||||
if (method_exists($this, 'team')) {
|
||||
$team = $this->team();
|
||||
if (filled($team)) {
|
||||
return is_object($team) ? $team->id : null;
|
||||
}
|
||||
}
|
||||
|
||||
// For models with direct team_id property
|
||||
if (property_exists($this, 'team_id') || isset($this->team_id)) {
|
||||
return $this->team_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
|
||||
*/
|
||||
protected function isProtectedEnvironmentVariable(string $key): bool
|
||||
{
|
||||
return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
|
||||
return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,10 +11,52 @@ use Illuminate\Support\Facades\Process;
|
||||
|
||||
trait ExecuteRemoteCommand
|
||||
{
|
||||
use SshRetryable;
|
||||
|
||||
public ?string $save = null;
|
||||
|
||||
public static int $batch_counter = 0;
|
||||
|
||||
private function redact_sensitive_info($text)
|
||||
{
|
||||
$text = remove_iip($text);
|
||||
|
||||
if (! isset($this->application)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$lockedVars = collect([]);
|
||||
|
||||
if (isset($this->application->environment_variables)) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$this->application->environment_variables
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$this->application->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 execute_remote_command(...$commands)
|
||||
{
|
||||
static::$batch_counter++;
|
||||
@@ -43,76 +85,188 @@ trait ExecuteRemoteCommand
|
||||
$command = parseLineForSudo($command, $this->server);
|
||||
}
|
||||
}
|
||||
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
$process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
|
||||
$output = str($output)->trim();
|
||||
if ($output->startsWith('╔')) {
|
||||
$output = "\n".$output;
|
||||
|
||||
// Check for cancellation before executing commands
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
|
||||
$sanitized_output = sanitize_utf8_text($output);
|
||||
|
||||
$new_log_entry = [
|
||||
'command' => remove_iip($command),
|
||||
'output' => remove_iip($sanitized_output),
|
||||
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => $hidden,
|
||||
'batch' => static::$batch_counter,
|
||||
];
|
||||
if (! $this->application_deployment_queue->logs) {
|
||||
$new_log_entry['order'] = 1;
|
||||
} else {
|
||||
try {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
// If existing logs are corrupted, start fresh
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
if (is_array($previous_logs)) {
|
||||
$new_log_entry['order'] = count($previous_logs) + 1;
|
||||
} else {
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
}
|
||||
$previous_logs[] = $new_log_entry;
|
||||
$maxRetries = config('constants.ssh.max_retries');
|
||||
$attempt = 0;
|
||||
$lastError = null;
|
||||
$commandExecuted = false;
|
||||
|
||||
while ($attempt < $maxRetries && ! $commandExecuted) {
|
||||
try {
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
// If JSON encoding still fails, use fallback with invalid sequences replacement
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
|
||||
$commandExecuted = true;
|
||||
} catch (\RuntimeException $e) {
|
||||
$lastError = $e;
|
||||
$errorMessage = $e->getMessage();
|
||||
// Only retry if it's an SSH connection error and we haven't exhausted retries
|
||||
if ($this->isRetryableSshError($errorMessage) && $attempt < $maxRetries - 1) {
|
||||
$attempt++;
|
||||
$delay = $this->calculateRetryDelay($attempt - 1);
|
||||
|
||||
$this->application_deployment_queue->save();
|
||||
// Track SSH retry event in Sentry
|
||||
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
|
||||
'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
|
||||
'command' => $this->redact_sensitive_info($command),
|
||||
'trait' => 'ExecuteRemoteCommand',
|
||||
]);
|
||||
|
||||
if ($this->save) {
|
||||
if (data_get($this->saved_outputs, $this->save, null) === null) {
|
||||
data_set($this->saved_outputs, $this->save, str());
|
||||
}
|
||||
if ($append) {
|
||||
$this->saved_outputs[$this->save] .= str($sanitized_output)->trim();
|
||||
$this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
|
||||
// Add log entry for the retry
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
|
||||
|
||||
// Check for cancellation during retry wait
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
sleep($delay);
|
||||
} else {
|
||||
$this->saved_outputs[$this->save] = str($sanitized_output)->trim();
|
||||
// Not retryable or max retries reached
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->application_deployment_queue->update([
|
||||
'current_process_id' => $process->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
// If we exhausted all retries and still failed
|
||||
if (! $commandExecuted && $lastError) {
|
||||
// Now we can set the status to FAILED since all retries have been exhausted
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
|
||||
$this->application_deployment_queue->save();
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
}
|
||||
throw $lastError;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual command with process handling
|
||||
*/
|
||||
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors)
|
||||
{
|
||||
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
$process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
|
||||
$output = str($output)->trim();
|
||||
if ($output->startsWith('╔')) {
|
||||
$output = "\n".$output;
|
||||
}
|
||||
|
||||
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
|
||||
$sanitized_output = sanitize_utf8_text($output);
|
||||
|
||||
$new_log_entry = [
|
||||
'command' => $this->redact_sensitive_info($command),
|
||||
'output' => $this->redact_sensitive_info($sanitized_output),
|
||||
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => $hidden,
|
||||
'batch' => static::$batch_counter,
|
||||
];
|
||||
if (! $this->application_deployment_queue->logs) {
|
||||
$new_log_entry['order'] = 1;
|
||||
} else {
|
||||
try {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
// If existing logs are corrupted, start fresh
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
if (is_array($previous_logs)) {
|
||||
$new_log_entry['order'] = count($previous_logs) + 1;
|
||||
} else {
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
}
|
||||
$previous_logs[] = $new_log_entry;
|
||||
|
||||
try {
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
// If JSON encoding still fails, use fallback with invalid sequences replacement
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->save();
|
||||
|
||||
if ($this->save) {
|
||||
if (data_get($this->saved_outputs, $this->save, null) === null) {
|
||||
data_set($this->saved_outputs, $this->save, str());
|
||||
}
|
||||
if ($append) {
|
||||
$this->saved_outputs[$this->save] .= str($sanitized_output)->trim();
|
||||
$this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
|
||||
} else {
|
||||
$this->saved_outputs[$this->save] = str($sanitized_output)->trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->application_deployment_queue->update([
|
||||
'current_process_id' => $process->id(),
|
||||
]);
|
||||
|
||||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
// Don't immediately set to FAILED - let the retry logic handle it
|
||||
// This prevents premature status changes during retryable SSH errors
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry for SSH retry attempts
|
||||
*/
|
||||
private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, string $errorMessage)
|
||||
{
|
||||
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
|
||||
|
||||
$new_log_entry = [
|
||||
'output' => $this->redact_sensitive_info($retryMessage),
|
||||
'type' => 'stdout',
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => false,
|
||||
'batch' => static::$batch_counter,
|
||||
];
|
||||
|
||||
if (! $this->application_deployment_queue->logs) {
|
||||
$new_log_entry['order'] = 1;
|
||||
$previous_logs = [];
|
||||
} else {
|
||||
try {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
if (is_array($previous_logs)) {
|
||||
$new_log_entry['order'] = count($previous_logs) + 1;
|
||||
} else {
|
||||
$previous_logs = [];
|
||||
$new_log_entry['order'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$previous_logs[] = $new_log_entry;
|
||||
|
||||
try {
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->save();
|
||||
}
|
||||
}
|
||||
|
||||
174
app/Traits/SshRetryable.php
Normal file
174
app/Traits/SshRetryable.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait SshRetryable
|
||||
{
|
||||
/**
|
||||
* Check if an error message indicates a retryable SSH connection error
|
||||
*/
|
||||
protected function isRetryableSshError(string $errorOutput): bool
|
||||
{
|
||||
$retryablePatterns = [
|
||||
'kex_exchange_identification',
|
||||
'Connection reset by peer',
|
||||
'Connection refused',
|
||||
'Connection timed out',
|
||||
'Connection closed by remote host',
|
||||
'ssh_exchange_identification',
|
||||
'Bad file descriptor',
|
||||
'Broken pipe',
|
||||
'No route to host',
|
||||
'Network is unreachable',
|
||||
'Host is down',
|
||||
'No buffer space available',
|
||||
'Connection reset by',
|
||||
'Permission denied, please try again',
|
||||
'Received disconnect from',
|
||||
'Disconnected from',
|
||||
'Connection to .* closed',
|
||||
'ssh: connect to host .* port .*: Connection',
|
||||
'Lost connection',
|
||||
'Timeout, server not responding',
|
||||
'Cannot assign requested address',
|
||||
'Network is down',
|
||||
'Host key verification failed',
|
||||
'Operation timed out',
|
||||
'Connection closed unexpectedly',
|
||||
'Remote host closed connection',
|
||||
'Authentication failed',
|
||||
'Too many authentication failures',
|
||||
];
|
||||
|
||||
$lowerErrorOutput = strtolower($errorOutput);
|
||||
foreach ($retryablePatterns as $pattern) {
|
||||
if (str_contains($lowerErrorOutput, strtolower($pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay for exponential backoff
|
||||
*/
|
||||
protected function calculateRetryDelay(int $attempt): int
|
||||
{
|
||||
$baseDelay = config('constants.ssh.retry_base_delay');
|
||||
$maxDelay = config('constants.ssh.retry_max_delay');
|
||||
$multiplier = config('constants.ssh.retry_multiplier');
|
||||
|
||||
$delay = min($baseDelay * pow($multiplier, $attempt), $maxDelay);
|
||||
|
||||
return (int) $delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback with SSH retry logic
|
||||
*
|
||||
* @param callable $callback The operation to execute
|
||||
* @param array $context Context for logging (server, command, etc.)
|
||||
* @param bool $throwError Whether to throw error on final failure
|
||||
* @return mixed The result from the callback
|
||||
*/
|
||||
protected function executeWithSshRetry(callable $callback, array $context = [], bool $throwError = true)
|
||||
{
|
||||
$maxRetries = config('constants.ssh.max_retries');
|
||||
$lastError = null;
|
||||
$lastErrorMessage = '';
|
||||
// Randomly fail the command with a key exchange error for testing
|
||||
// if (random_int(1, 10) === 1) { // 10% chance to fail
|
||||
// ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
|
||||
// throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
|
||||
// }
|
||||
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = $e;
|
||||
$lastErrorMessage = $e->getMessage();
|
||||
|
||||
// Check if it's retryable and not the last attempt
|
||||
if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
|
||||
$delay = $this->calculateRetryDelay($attempt);
|
||||
|
||||
// Track SSH retry event in Sentry
|
||||
$this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
|
||||
|
||||
// Add deployment log if available (for ExecuteRemoteCommand trait)
|
||||
if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
|
||||
$this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
|
||||
}
|
||||
|
||||
sleep($delay);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not retryable or max retries reached
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if ($attempt >= $maxRetries) {
|
||||
Log::error('SSH operation failed after all retries', array_merge($context, [
|
||||
'attempts' => $attempt,
|
||||
'error' => $lastErrorMessage,
|
||||
]));
|
||||
}
|
||||
|
||||
if ($throwError && $lastError) {
|
||||
// If the error message is empty, provide a more meaningful one
|
||||
if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') {
|
||||
$contextInfo = isset($context['server']) ? " to server {$context['server']}" : '';
|
||||
$attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : '';
|
||||
throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());
|
||||
}
|
||||
throw $lastError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track SSH retry event in Sentry
|
||||
*/
|
||||
protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
|
||||
{
|
||||
// Only track in production/cloud instances
|
||||
if (isDev() || ! config('constants.sentry.sentry_dsn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app('sentry')->captureMessage(
|
||||
'SSH connection retry triggered',
|
||||
\Sentry\Severity::warning(),
|
||||
[
|
||||
'extra' => [
|
||||
'attempt' => $attempt,
|
||||
'max_retries' => $maxRetries,
|
||||
'delay_seconds' => $delay,
|
||||
'error_message' => $errorMessage,
|
||||
'context' => $context,
|
||||
'retryable_error' => true,
|
||||
],
|
||||
'tags' => [
|
||||
'component' => 'ssh_retry',
|
||||
'error_type' => 'connection_retry',
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let Sentry tracking errors break the SSH retry flow
|
||||
Log::warning('Failed to track SSH retry event in Sentry', [
|
||||
'error' => $e->getMessage(),
|
||||
'original_attempt' => $attempt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user